-
Notifications
You must be signed in to change notification settings - Fork 4.6k
api-fetch: clean up error handling #71458
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,21 +61,6 @@ function registerMiddleware( middleware: APIFetchMiddleware ) { | |
| middlewares.unshift( middleware ); | ||
| } | ||
|
|
||
| /** | ||
| * Checks the status of a response, throwing the Response as an error if | ||
| * it is outside the 200 range. | ||
| * | ||
| * @param response | ||
| * @return The response if the status is in the 200 range. | ||
| */ | ||
| const checkStatus = ( response: Response ) => { | ||
| if ( response.status >= 200 && response.status < 300 ) { | ||
| return response; | ||
| } | ||
|
|
||
| throw response; | ||
| }; | ||
|
|
||
| const defaultFetchHandler: FetchHandler = ( nextOptions ) => { | ||
| const { url, path, data, parse = true, ...remainingOptions } = nextOptions; | ||
| let { body, headers } = nextOptions; | ||
|
|
@@ -89,7 +74,7 @@ const defaultFetchHandler: FetchHandler = ( nextOptions ) => { | |
| headers[ 'Content-Type' ] = 'application/json'; | ||
| } | ||
|
|
||
| const responsePromise = window.fetch( | ||
| const responsePromise = globalThis.fetch( | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm replacing |
||
| // Fall back to explicitly passing `window.location` which is the behavior if `undefined` is passed. | ||
| url || path || window.location.href, | ||
| { | ||
|
|
@@ -101,13 +86,15 @@ const defaultFetchHandler: FetchHandler = ( nextOptions ) => { | |
| ); | ||
|
|
||
| return responsePromise.then( | ||
| ( value ) => | ||
| Promise.resolve( value ) | ||
| .then( checkStatus ) | ||
| .catch( ( response ) => parseAndThrowError( response, parse ) ) | ||
| .then( ( response ) => | ||
| parseResponseAndNormalizeError( response, parse ) | ||
| ), | ||
| ( response ) => { | ||
| // If the response is not 2xx, still parse the response body as JSON | ||
| // but throw the JSON as error. | ||
| if ( ! response.ok ) { | ||
| return parseAndThrowError( response, parse ); | ||
| } | ||
|
|
||
| return parseResponseAndNormalizeError( response, parse ); | ||
| }, | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's been a lot of promises that can be replaced with synchronous code. |
||
| ( err ) => { | ||
| // Re-throw AbortError for the users to handle it themselves. | ||
| if ( err && err.name === 'AbortError' ) { | ||
|
|
@@ -116,7 +103,7 @@ const defaultFetchHandler: FetchHandler = ( nextOptions ) => { | |
|
|
||
| // If the browser reports being offline, we'll just assume that | ||
| // this is why the request failed. | ||
| if ( ! window.navigator.onLine ) { | ||
| if ( ! globalThis.navigator.onLine ) { | ||
| throw { | ||
| code: 'offline_error', | ||
| message: __( | ||
|
|
@@ -189,10 +176,17 @@ const apiFetch: apiFetch = ( options ) => { | |
| } | ||
|
|
||
| // If the nonce is invalid, refresh it and try again. | ||
| return window | ||
| return globalThis | ||
| .fetch( apiFetch.nonceEndpoint! ) | ||
| .then( checkStatus ) | ||
| .then( ( data ) => data.text() ) | ||
| .then( ( response ) => { | ||
| // If the nonce refresh fails, it means we failed to recover from the original | ||
| // `rest_cookie_invalid_nonce` error and that it's time to finally re-throw it. | ||
| if ( ! response.ok ) { | ||
| return Promise.reject( error ); | ||
| } | ||
|
Comment on lines
+182
to
+186
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not a blocker for this refactoring, but the error will never be rethrown here. The response returned by the nonce refresh endpoint is always I spent some time trying to fix it last year. Here's the failed result (#67812).
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When I was testing this, it was easy to get an error response from the endpoint. Just send a request that is not authenticated (or incorrectly authenticated): There is a 400 error and
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, this works when the server actually fails. The problem I mentioned is related to partially tokens "corrupted tokens, nonce refreshing works, but the returned value then fails subsequent requests and Again, this isn't a blocker here, just something I wanted to cross-reference.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see it now 🙂 IMO there shouldn't be a nonce refresh infinite loop: the REST request should be also processed anonymously, which often returns a result, and the anonymous session and anonymous nonce should match. Nonce refresh loop can happen when there is a mismatch between the I think it's a bug that I didn't read the entire discussion and feedback in #67812, but it looks like something that's definitely worth trying again. I think I know enough about WordPress auth to help get it through the finish line 🙂
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks, happy to collaborate on that resolution @jsnajdr! |
||
|
|
||
| return response.text(); | ||
| } ) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This fixes the error thrown when nonce refresh fails. The workflow is:
Now, the original code, on nonce refresh failure, threw the I added two new unit tests that test for this. One of them will fail without this change. |
||
| .then( ( text ) => { | ||
| apiFetch.nonceMiddleware!.nonce = text; | ||
| return apiFetch( options ); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -63,9 +63,9 @@ const mediaUploadMiddleware: APIFetchMiddleware = ( options, next ) => { | |
| }; | ||
|
|
||
| return next( { ...options, parse: false } ) | ||
| .catch( ( response ) => { | ||
| .catch( ( response: Response ) => { | ||
| // `response` could actually be an error thrown by `defaultFetchHandler`. | ||
| if ( ! response.headers ) { | ||
| if ( ! ( response instanceof globalThis.Response ) ) { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| return Promise.reject( response ); | ||
| } | ||
|
|
||
|
|
@@ -92,7 +92,7 @@ const mediaUploadMiddleware: APIFetchMiddleware = ( options, next ) => { | |
| } | ||
| return parseAndThrowError( response, options.parse ); | ||
| } ) | ||
| .then( ( response ) => | ||
| .then( ( response: Response ) => | ||
| parseResponseAndNormalizeError( response, options.parse ) | ||
| ); | ||
| }; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,3 @@ | ||
| /** | ||
| * Internal dependencies | ||
| */ | ||
| import apiFetch from '../'; | ||
|
|
||
| /** | ||
| * Mock response value for a successful fetch. | ||
| * | ||
|
|
@@ -15,9 +10,15 @@ const DEFAULT_FETCH_MOCK_RETURN = { | |
| }; | ||
|
|
||
| describe( 'apiFetch', () => { | ||
| let apiFetch; | ||
| const originalFetch = globalThis.fetch; | ||
|
|
||
| beforeEach( () => { | ||
| beforeEach( async () => { | ||
| // Reset the `apiFetch` module before each test to clear | ||
| // internal variables (middlewares, fetch handler, etc.). | ||
| jest.resetModules(); | ||
| apiFetch = ( await import( '../' ) ).default; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here I load a new instance of |
||
|
|
||
| globalThis.fetch = jest.fn(); | ||
| } ); | ||
|
|
||
|
|
@@ -241,6 +242,92 @@ describe( 'apiFetch', () => { | |
| ).rejects.toBe( mockResponse ); | ||
| } ); | ||
|
|
||
| it( 'should refetch after successful nonce refresh', async () => { | ||
| apiFetch.nonceMiddleware = | ||
| apiFetch.createNonceMiddleware( 'old-nonce' ); | ||
| apiFetch.use( apiFetch.nonceMiddleware ); | ||
| apiFetch.nonceEndpoint = '/rest-nonce'; | ||
|
|
||
| globalThis.fetch.mockImplementation( async ( path, options ) => { | ||
| if ( path.startsWith( '/random' ) ) { | ||
| if ( options?.headers[ 'X-WP-Nonce' ] === 'new-nonce' ) { | ||
| return { | ||
| ok: true, | ||
| status: 200, | ||
| json: async () => ( { code: 'success' } ), | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| ok: false, | ||
| status: 403, | ||
| json: async () => ( { code: 'rest_cookie_invalid_nonce' } ), | ||
| }; | ||
| } | ||
|
|
||
| if ( path.startsWith( '/rest-nonce' ) ) { | ||
| return { | ||
| ok: true, | ||
| status: 200, | ||
| text: async () => 'new-nonce', | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| ok: false, | ||
| status: 404, | ||
| json: async () => ( { code: 'rest_no_route' } ), | ||
| }; | ||
| } ); | ||
|
|
||
| await expect( apiFetch( { path: '/random' } ) ).resolves.toEqual( { | ||
| code: 'success', | ||
| } ); | ||
| } ); | ||
|
|
||
| it( 'should fail with rest_cookie_invalid_nonce after failed nonce refresh', async () => { | ||
| apiFetch.nonceMiddleware = | ||
| apiFetch.createNonceMiddleware( 'old-nonce' ); | ||
| apiFetch.use( apiFetch.nonceMiddleware ); | ||
| apiFetch.nonceEndpoint = '/rest-nonce'; | ||
|
|
||
| globalThis.fetch.mockImplementation( async ( path, options ) => { | ||
| if ( path.startsWith( '/random' ) ) { | ||
| if ( options?.headers[ 'X-WP-Nonce' ] === 'new-nonce' ) { | ||
| return { | ||
| ok: true, | ||
| status: 200, | ||
| json: async () => ( { code: 'success' } ), | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| ok: false, | ||
| status: 403, | ||
| json: async () => ( { code: 'rest_cookie_invalid_nonce' } ), | ||
| }; | ||
| } | ||
|
|
||
| if ( path.startsWith( '/rest-nonce' ) ) { | ||
| return { | ||
| ok: false, | ||
| status: 400, | ||
| text: async () => '0', | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| ok: false, | ||
| status: 404, | ||
| json: async () => ( { code: 'rest_no_route' } ), | ||
| }; | ||
| } ); | ||
|
|
||
| await expect( apiFetch( { path: '/random' } ) ).rejects.toEqual( { | ||
| code: 'rest_cookie_invalid_nonce', | ||
| } ); | ||
| } ); | ||
|
|
||
| it( 'should not use the default fetch handler when using a custom fetch handler', async () => { | ||
| const customFetchHandler = jest.fn(); | ||
|
|
||
|
|
@@ -256,13 +343,8 @@ describe( 'apiFetch', () => { | |
| } ); | ||
|
|
||
| it( 'should run the last-registered user-defined middleware first', async () => { | ||
| // This could potentially impact other tests in that a lingering | ||
| // middleware is left. For the purposes of this test, it is sufficient | ||
| // to ensure that the last-registered middleware receives the original | ||
| // options object. It also assumes that some built-in middleware would | ||
| // either mutate or clone the original options if the extra middleware | ||
| // had been pushed to the stack. | ||
| expect.assertions( 1 ); | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test is now isolated from other tests, so it doesn't need to run as the last test any more. It also needs its own |
||
| // The test assumes that some built-in middleware will either mutate or clone | ||
| // the original options if the extra middleware had been pushed to the stack. | ||
|
|
||
| const expectedOptions = {}; | ||
|
|
||
|
|
@@ -272,6 +354,9 @@ describe( 'apiFetch', () => { | |
| return next( actualOptions ); | ||
| } ); | ||
|
|
||
| // Set a custom fetch handler to avoid using the default fetch handler. | ||
| apiFetch.setFetchHandler( jest.fn() ); | ||
|
|
||
| await apiFetch( expectedOptions ); | ||
| } ); | ||
| } ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,47 +3,23 @@ | |
| */ | ||
| import { __ } from '@wordpress/i18n'; | ||
|
|
||
| /** | ||
| * Parses the apiFetch response. | ||
| * | ||
| * @param response | ||
| * @param shouldParseResponse | ||
| * | ||
| * @return Parsed response. | ||
| */ | ||
| const parseResponse = ( response: Response, shouldParseResponse = true ) => { | ||
| if ( shouldParseResponse ) { | ||
| if ( response.status === 204 ) { | ||
| return null; | ||
| } | ||
|
|
||
| return response.json ? response.json() : Promise.reject( response ); | ||
| } | ||
|
|
||
| return response; | ||
| }; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is now fully inlined into |
||
|
|
||
| /** | ||
| * Calls the `json` function on the Response, throwing an error if the response | ||
| * doesn't have a json function or if parsing the json itself fails. | ||
| * | ||
| * @param response | ||
| * @return Parsed response. | ||
| */ | ||
| const parseJsonAndNormalizeError = ( response: Response ) => { | ||
| const invalidJsonError = { | ||
| code: 'invalid_json', | ||
| message: __( 'The response is not a valid JSON response.' ), | ||
| }; | ||
|
|
||
| if ( ! response || ! response.json ) { | ||
| throw invalidJsonError; | ||
| async function parseJsonAndNormalizeError( response: Response ) { | ||
| try { | ||
| return await response.json(); | ||
| } catch { | ||
| throw { | ||
| code: 'invalid_json', | ||
| message: __( 'The response is not a valid JSON response.' ), | ||
| }; | ||
| } | ||
|
|
||
| return response.json().catch( () => { | ||
| throw invalidJsonError; | ||
| } ); | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Parses the apiFetch response properly and normalize response errors. | ||
|
|
@@ -53,36 +29,36 @@ const parseJsonAndNormalizeError = ( response: Response ) => { | |
| * | ||
| * @return Parsed response. | ||
| */ | ||
| export const parseResponseAndNormalizeError = ( | ||
| export async function parseResponseAndNormalizeError( | ||
| response: Response, | ||
| shouldParseResponse = true | ||
| ) => { | ||
| return Promise.resolve( | ||
| parseResponse( response, shouldParseResponse ) | ||
| ).catch( ( res ) => parseAndThrowError( res, shouldParseResponse ) ); | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This code didn't make much sense. |
||
| }; | ||
| ) { | ||
| if ( ! shouldParseResponse ) { | ||
| return response; | ||
| } | ||
|
|
||
| if ( response.status === 204 ) { | ||
| return null; | ||
| } | ||
|
|
||
| return await parseJsonAndNormalizeError( response ); | ||
| } | ||
|
|
||
| /** | ||
| * Parses a response, throwing an error if parsing the response fails. | ||
| * | ||
| * @param response | ||
| * @param shouldParseResponse | ||
| * @return Parsed response. | ||
| * @return Never returns, always throws. | ||
| */ | ||
| export function parseAndThrowError( | ||
| export async function parseAndThrowError( | ||
| response: Response, | ||
| shouldParseResponse = true | ||
| ) { | ||
| if ( ! shouldParseResponse ) { | ||
| throw response; | ||
| } | ||
|
|
||
| return parseJsonAndNormalizeError( response ).then( ( error ) => { | ||
| const unknownError = { | ||
| code: 'unknown_error', | ||
| message: __( 'An unknown error occurred.' ), | ||
| }; | ||
|
|
||
| throw error || unknownError; | ||
| } ); | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| // Parse the response JSON and throw it as an error. | ||
| throw await parseJsonAndNormalizeError( response ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This
checkStatusfunction is identical to what nativeresponse.okdoes. We can replace it completely. Also note that it's synchronous.