Skip to content

Commit 2646370

Browse files
authored
api-fetch: clean up error handling (#71458)
* api-fetch: add unit tests for nonce refresh, reset apiFetch module on each test * api-fetch: clean up error handling
1 parent 4aa4723 commit 2646370

File tree

4 files changed

+147
-92
lines changed

4 files changed

+147
-92
lines changed

packages/api-fetch/src/index.ts

Lines changed: 21 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -61,21 +61,6 @@ function registerMiddleware( middleware: APIFetchMiddleware ) {
6161
middlewares.unshift( middleware );
6262
}
6363

64-
/**
65-
* Checks the status of a response, throwing the Response as an error if
66-
* it is outside the 200 range.
67-
*
68-
* @param response
69-
* @return The response if the status is in the 200 range.
70-
*/
71-
const checkStatus = ( response: Response ) => {
72-
if ( response.status >= 200 && response.status < 300 ) {
73-
return response;
74-
}
75-
76-
throw response;
77-
};
78-
7964
const defaultFetchHandler: FetchHandler = ( nextOptions ) => {
8065
const { url, path, data, parse = true, ...remainingOptions } = nextOptions;
8166
let { body, headers } = nextOptions;
@@ -89,7 +74,7 @@ const defaultFetchHandler: FetchHandler = ( nextOptions ) => {
8974
headers[ 'Content-Type' ] = 'application/json';
9075
}
9176

92-
const responsePromise = window.fetch(
77+
const responsePromise = globalThis.fetch(
9378
// Fall back to explicitly passing `window.location` which is the behavior if `undefined` is passed.
9479
url || path || window.location.href,
9580
{
@@ -101,13 +86,15 @@ const defaultFetchHandler: FetchHandler = ( nextOptions ) => {
10186
);
10287

10388
return responsePromise.then(
104-
( value ) =>
105-
Promise.resolve( value )
106-
.then( checkStatus )
107-
.catch( ( response ) => parseAndThrowError( response, parse ) )
108-
.then( ( response ) =>
109-
parseResponseAndNormalizeError( response, parse )
110-
),
89+
( response ) => {
90+
// If the response is not 2xx, still parse the response body as JSON
91+
// but throw the JSON as error.
92+
if ( ! response.ok ) {
93+
return parseAndThrowError( response, parse );
94+
}
95+
96+
return parseResponseAndNormalizeError( response, parse );
97+
},
11198
( err ) => {
11299
// Re-throw AbortError for the users to handle it themselves.
113100
if ( err && err.name === 'AbortError' ) {
@@ -116,7 +103,7 @@ const defaultFetchHandler: FetchHandler = ( nextOptions ) => {
116103

117104
// If the browser reports being offline, we'll just assume that
118105
// this is why the request failed.
119-
if ( ! window.navigator.onLine ) {
106+
if ( ! globalThis.navigator.onLine ) {
120107
throw {
121108
code: 'offline_error',
122109
message: __(
@@ -189,10 +176,17 @@ const apiFetch: apiFetch = ( options ) => {
189176
}
190177

191178
// If the nonce is invalid, refresh it and try again.
192-
return window
179+
return globalThis
193180
.fetch( apiFetch.nonceEndpoint! )
194-
.then( checkStatus )
195-
.then( ( data ) => data.text() )
181+
.then( ( response ) => {
182+
// If the nonce refresh fails, it means we failed to recover from the original
183+
// `rest_cookie_invalid_nonce` error and that it's time to finally re-throw it.
184+
if ( ! response.ok ) {
185+
return Promise.reject( error );
186+
}
187+
188+
return response.text();
189+
} )
196190
.then( ( text ) => {
197191
apiFetch.nonceMiddleware!.nonce = text;
198192
return apiFetch( options );

packages/api-fetch/src/middlewares/media-upload.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ const mediaUploadMiddleware: APIFetchMiddleware = ( options, next ) => {
6363
};
6464

6565
return next( { ...options, parse: false } )
66-
.catch( ( response ) => {
66+
.catch( ( response: Response ) => {
6767
// `response` could actually be an error thrown by `defaultFetchHandler`.
68-
if ( ! response.headers ) {
68+
if ( ! ( response instanceof globalThis.Response ) ) {
6969
return Promise.reject( response );
7070
}
7171

@@ -92,7 +92,7 @@ const mediaUploadMiddleware: APIFetchMiddleware = ( options, next ) => {
9292
}
9393
return parseAndThrowError( response, options.parse );
9494
} )
95-
.then( ( response ) =>
95+
.then( ( response: Response ) =>
9696
parseResponseAndNormalizeError( response, options.parse )
9797
);
9898
};

packages/api-fetch/src/test/index.js

Lines changed: 98 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
1-
/**
2-
* Internal dependencies
3-
*/
4-
import apiFetch from '../';
5-
61
/**
72
* Mock response value for a successful fetch.
83
*
@@ -15,9 +10,15 @@ const DEFAULT_FETCH_MOCK_RETURN = {
1510
};
1611

1712
describe( 'apiFetch', () => {
13+
let apiFetch;
1814
const originalFetch = globalThis.fetch;
1915

20-
beforeEach( () => {
16+
beforeEach( async () => {
17+
// Reset the `apiFetch` module before each test to clear
18+
// internal variables (middlewares, fetch handler, etc.).
19+
jest.resetModules();
20+
apiFetch = ( await import( '../' ) ).default;
21+
2122
globalThis.fetch = jest.fn();
2223
} );
2324

@@ -241,6 +242,92 @@ describe( 'apiFetch', () => {
241242
).rejects.toBe( mockResponse );
242243
} );
243244

245+
it( 'should refetch after successful nonce refresh', async () => {
246+
apiFetch.nonceMiddleware =
247+
apiFetch.createNonceMiddleware( 'old-nonce' );
248+
apiFetch.use( apiFetch.nonceMiddleware );
249+
apiFetch.nonceEndpoint = '/rest-nonce';
250+
251+
globalThis.fetch.mockImplementation( async ( path, options ) => {
252+
if ( path.startsWith( '/random' ) ) {
253+
if ( options?.headers[ 'X-WP-Nonce' ] === 'new-nonce' ) {
254+
return {
255+
ok: true,
256+
status: 200,
257+
json: async () => ( { code: 'success' } ),
258+
};
259+
}
260+
261+
return {
262+
ok: false,
263+
status: 403,
264+
json: async () => ( { code: 'rest_cookie_invalid_nonce' } ),
265+
};
266+
}
267+
268+
if ( path.startsWith( '/rest-nonce' ) ) {
269+
return {
270+
ok: true,
271+
status: 200,
272+
text: async () => 'new-nonce',
273+
};
274+
}
275+
276+
return {
277+
ok: false,
278+
status: 404,
279+
json: async () => ( { code: 'rest_no_route' } ),
280+
};
281+
} );
282+
283+
await expect( apiFetch( { path: '/random' } ) ).resolves.toEqual( {
284+
code: 'success',
285+
} );
286+
} );
287+
288+
it( 'should fail with rest_cookie_invalid_nonce after failed nonce refresh', async () => {
289+
apiFetch.nonceMiddleware =
290+
apiFetch.createNonceMiddleware( 'old-nonce' );
291+
apiFetch.use( apiFetch.nonceMiddleware );
292+
apiFetch.nonceEndpoint = '/rest-nonce';
293+
294+
globalThis.fetch.mockImplementation( async ( path, options ) => {
295+
if ( path.startsWith( '/random' ) ) {
296+
if ( options?.headers[ 'X-WP-Nonce' ] === 'new-nonce' ) {
297+
return {
298+
ok: true,
299+
status: 200,
300+
json: async () => ( { code: 'success' } ),
301+
};
302+
}
303+
304+
return {
305+
ok: false,
306+
status: 403,
307+
json: async () => ( { code: 'rest_cookie_invalid_nonce' } ),
308+
};
309+
}
310+
311+
if ( path.startsWith( '/rest-nonce' ) ) {
312+
return {
313+
ok: false,
314+
status: 400,
315+
text: async () => '0',
316+
};
317+
}
318+
319+
return {
320+
ok: false,
321+
status: 404,
322+
json: async () => ( { code: 'rest_no_route' } ),
323+
};
324+
} );
325+
326+
await expect( apiFetch( { path: '/random' } ) ).rejects.toEqual( {
327+
code: 'rest_cookie_invalid_nonce',
328+
} );
329+
} );
330+
244331
it( 'should not use the default fetch handler when using a custom fetch handler', async () => {
245332
const customFetchHandler = jest.fn();
246333

@@ -256,13 +343,8 @@ describe( 'apiFetch', () => {
256343
} );
257344

258345
it( 'should run the last-registered user-defined middleware first', async () => {
259-
// This could potentially impact other tests in that a lingering
260-
// middleware is left. For the purposes of this test, it is sufficient
261-
// to ensure that the last-registered middleware receives the original
262-
// options object. It also assumes that some built-in middleware would
263-
// either mutate or clone the original options if the extra middleware
264-
// had been pushed to the stack.
265-
expect.assertions( 1 );
346+
// The test assumes that some built-in middleware will either mutate or clone
347+
// the original options if the extra middleware had been pushed to the stack.
266348

267349
const expectedOptions = {};
268350

@@ -272,6 +354,9 @@ describe( 'apiFetch', () => {
272354
return next( actualOptions );
273355
} );
274356

357+
// Set a custom fetch handler to avoid using the default fetch handler.
358+
apiFetch.setFetchHandler( jest.fn() );
359+
275360
await apiFetch( expectedOptions );
276361
} );
277362
} );

packages/api-fetch/src/utils/response.ts

Lines changed: 25 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,47 +3,23 @@
33
*/
44
import { __ } from '@wordpress/i18n';
55

6-
/**
7-
* Parses the apiFetch response.
8-
*
9-
* @param response
10-
* @param shouldParseResponse
11-
*
12-
* @return Parsed response.
13-
*/
14-
const parseResponse = ( response: Response, shouldParseResponse = true ) => {
15-
if ( shouldParseResponse ) {
16-
if ( response.status === 204 ) {
17-
return null;
18-
}
19-
20-
return response.json ? response.json() : Promise.reject( response );
21-
}
22-
23-
return response;
24-
};
25-
266
/**
277
* Calls the `json` function on the Response, throwing an error if the response
288
* doesn't have a json function or if parsing the json itself fails.
299
*
3010
* @param response
3111
* @return Parsed response.
3212
*/
33-
const parseJsonAndNormalizeError = ( response: Response ) => {
34-
const invalidJsonError = {
35-
code: 'invalid_json',
36-
message: __( 'The response is not a valid JSON response.' ),
37-
};
38-
39-
if ( ! response || ! response.json ) {
40-
throw invalidJsonError;
13+
async function parseJsonAndNormalizeError( response: Response ) {
14+
try {
15+
return await response.json();
16+
} catch {
17+
throw {
18+
code: 'invalid_json',
19+
message: __( 'The response is not a valid JSON response.' ),
20+
};
4121
}
42-
43-
return response.json().catch( () => {
44-
throw invalidJsonError;
45-
} );
46-
};
22+
}
4723

4824
/**
4925
* Parses the apiFetch response properly and normalize response errors.
@@ -53,36 +29,36 @@ const parseJsonAndNormalizeError = ( response: Response ) => {
5329
*
5430
* @return Parsed response.
5531
*/
56-
export const parseResponseAndNormalizeError = (
32+
export async function parseResponseAndNormalizeError(
5733
response: Response,
5834
shouldParseResponse = true
59-
) => {
60-
return Promise.resolve(
61-
parseResponse( response, shouldParseResponse )
62-
).catch( ( res ) => parseAndThrowError( res, shouldParseResponse ) );
63-
};
35+
) {
36+
if ( ! shouldParseResponse ) {
37+
return response;
38+
}
39+
40+
if ( response.status === 204 ) {
41+
return null;
42+
}
43+
44+
return await parseJsonAndNormalizeError( response );
45+
}
6446

6547
/**
6648
* Parses a response, throwing an error if parsing the response fails.
6749
*
6850
* @param response
6951
* @param shouldParseResponse
70-
* @return Parsed response.
52+
* @return Never returns, always throws.
7153
*/
72-
export function parseAndThrowError(
54+
export async function parseAndThrowError(
7355
response: Response,
7456
shouldParseResponse = true
7557
) {
7658
if ( ! shouldParseResponse ) {
7759
throw response;
7860
}
7961

80-
return parseJsonAndNormalizeError( response ).then( ( error ) => {
81-
const unknownError = {
82-
code: 'unknown_error',
83-
message: __( 'An unknown error occurred.' ),
84-
};
85-
86-
throw error || unknownError;
87-
} );
62+
// Parse the response JSON and throw it as an error.
63+
throw await parseJsonAndNormalizeError( response );
8864
}

0 commit comments

Comments
 (0)