Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 21 additions & 27 deletions packages/api-fetch/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This checkStatus function is identical to what native response.ok does. We can replace it completely. Also note that it's synchronous.


const defaultFetchHandler: FetchHandler = ( nextOptions ) => {
const { url, path, data, parse = true, ...remainingOptions } = nextOptions;
let { body, headers } = nextOptions;
Expand All @@ -89,7 +74,7 @@ const defaultFetchHandler: FetchHandler = ( nextOptions ) => {
headers[ 'Content-Type' ] = 'application/json';
}

const responsePromise = window.fetch(
const responsePromise = globalThis.fetch(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm replacing window with the more universal globalThis. Not that useful right away, but good for potential compatibility with web workers or Node.js.

// Fall back to explicitly passing `window.location` which is the behavior if `undefined` is passed.
url || path || window.location.href,
{
Expand All @@ -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 );
},
Copy link
Member Author

Choose a reason for hiding this comment

The 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. value was not a promise, it's the Response object. No need to turn it into a promise. The handler can be converted into a simple if/else block. If the response is OK, return response, if it's not OK, extract the error from the response (it can be a JSON body of a 4xx response, for example) and throw the object as an error.

( err ) => {
// Re-throw AbortError for the users to handle it themselves.
if ( err && err.name === 'AbortError' ) {
Expand All @@ -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: __(
Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The 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 OK, even when "refreshed" nonces don't work.

I spent some time trying to fix it last year. Here's the failed result (#67812).

Copy link
Member Author

Choose a reason for hiding this comment

The 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):

$ curl -i 'http://localhost:9001/wp-admin/admin-ajax.php?action=rest-nonce'  
HTTP/1.1 400 Bad Request
Date: Tue, 02 Sep 2025 11:18:52 GMT
Server: Apache/2.4.65 (Debian)
X-Powered-By: PHP/8.2.29
X-Robots-Tag: noindex
X-Content-Type-Options: nosniff
Expires: Wed, 11 Jan 1984 05:00:00 GMT
Cache-Control: no-cache, must-revalidate, max-age=0, no-store, private
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: SAMEORIGIN
Content-Length: 1
Connection: close
Content-Type: text/html; charset=UTF-8

0%

There is a 400 error and 0 in the body. Sending a similar request with a bad wordpress_logged_in cookie had the same result.

Copy link
Member

Choose a reason for hiding this comment

The 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 apiFetch gets stuck in the loop. Repro steps from #67812 still apply.

Again, this isn't a blocker here, just something I wanted to cross-reference.

Copy link
Member Author

@jsnajdr jsnajdr Sep 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see it now 🙂 wp_create_nonce supports creating nonces also for logged-out, anonymous sessions. So, when your wordress_logged_in cookie is expired, then wp_get_current_user() returns 0, wp_get_session_token() returns '', the session is anonymous. But the admin-ajax.php?action=rest-nonce will happily return a nonce, it's just for a logged-out user.

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 wordpress_logged_in and the wordpress auth cookies. Then the REST request can be authenticated (uses only wordpress_logged_in) and the admin-ajax.php is not authenticated (uses also wordpress, scoped to /wp-admin path).

I think it's a bug that admin-ajax.php?action=rest-nonce returns a nonce also for an anonymous user. This is completely useless in a WP Admin/REST API context. It's a feature of wp_create_nonce that's probably useful in other use cases, but for rest-nonce we should disable it. WP Admin user is always authenticated.

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 🙂

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, happy to collaborate on that resolution @jsnajdr!


return response.text();
} )
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes the error thrown when nonce refresh fails. The workflow is:

  1. Try to fetch from the original endpoint, e.g., /wp/v2/posts
  2. If the response is rest_cookie_invalid_nonce, we fetch a new nonce from /wp-admin/admin-ajax.php?action=rest-nonce
  3. This admin-ajax.php is very traditional, it doesn't return a JSON, but a text response with the new nonce.
  4. If the nonce refresh fails, it's a 400 error with a text body that contains 0.
  5. If the nonce refresh succeeds, we try the original fetch again.

Now, the original code, on nonce refresh failure, threw the Response object with the { status: 200, text: '0' } content! Because that's what the checkStatus helper throws. For an apiFetch( { path: '/wp/v2/posts' } ) call this doesn't make any sense. It's much better to throw the original rest_cookie_invalid_nonce error. Because it comes from a /wp/v2/posts response. We failed to recover from the error, so we have to throw it to the caller.

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 );
Expand Down
6 changes: 3 additions & 3 deletions packages/api-fetch/src/middlewares/media-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) ) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apiFetch with parse: false throws either a Response or a regular Error. Seems to me that instanceOf is a better check than checking presence of .headers.

return Promise.reject( response );
}

Expand All @@ -92,7 +92,7 @@ const mediaUploadMiddleware: APIFetchMiddleware = ( options, next ) => {
}
return parseAndThrowError( response, options.parse );
} )
.then( ( response ) =>
.then( ( response: Response ) =>
parseResponseAndNormalizeError( response, options.parse )
);
};
Expand Down
111 changes: 98 additions & 13 deletions packages/api-fetch/src/test/index.js
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.
*
Expand All @@ -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;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here I load a new instance of apiFetch module for every test, so that internal variables (middlewares, fetch handler) are reset to initial values for each test.


globalThis.fetch = jest.fn();
} );

Expand Down Expand Up @@ -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();

Expand All @@ -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 );
Copy link
Member Author

Choose a reason for hiding this comment

The 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 setFetchHandler call, because it no longer gets one for free from the previous (custom fetch handler) test 🙂

// 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 = {};

Expand All @@ -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 );
} );
} );
74 changes: 25 additions & 49 deletions packages/api-fetch/src/utils/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now fully inlined into parseResponseAndNormalizeError.


/**
* 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.
Expand All @@ -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 ) );
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code didn't make much sense. parseResponse can fail only when response.json() throws an error. But then parseJsonAndNormalizeError will detect that it's not a real Response (doesn't have a .json field) and will throw invalid_json error. Instead of this, we can just call parseJsonAndNormalizeError right away.

};
) {
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;
} );
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseJsonAndNormalizeError always either succeeds or throws an invalid_json error. This catch handler is redundant, the unknown_error is never thrown.

// Parse the response JSON and throw it as an error.
throw await parseJsonAndNormalizeError( response );
}
Loading