diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index ce6470856..d136c3a38 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -2,16 +2,26 @@ import apiFetch from '@wordpress/api-fetch'; import { getQueryArg } from '@wordpress/url'; import { __ } from '@wordpress/i18n'; import { getGBKit, POST_FALLBACKS } from './bridge'; -import { info, error as logError } from './logger'; +import { info, warn, error as logError } from './logger'; import { ensureTrailingSlash, stripTrailingSlash } from './url'; /** * @typedef {import('@wordpress/api-fetch').APIFetchMiddleware} APIFetchMiddleware + * @typedef {import('@wordpress/api-fetch').FetchHandler} FetchHandler */ /** Matches `/wp/v2/media` but not sub-paths like `/wp/v2/media/123`. */ const MEDIA_UPLOAD_PATH = /^\/wp\/v2\/media(\?|$)/; +/** Methods safe to repeat because they do not change server state. */ +const RETRYABLE_METHODS = [ 'GET', 'HEAD', 'OPTIONS' ]; + +/** Base delay before each retry; jitter of up to the same amount is added. */ +const RETRY_DELAYS_MS = [ 500, 2000 ]; + +/** Longest `Retry-After` delay worth waiting for; longer ones fail at once. */ +const MAX_RETRY_AFTER_MS = 10_000; + /** * Initializes the API fetch configuration and middleware. * @@ -38,6 +48,9 @@ export function configureApiFetch() { apiFetch.use( apiFetch.createPreloadingMiddleware( preloadData ?? defaultPreloadData ) ); + apiFetch.setFetchHandler( + withRateLimitRetry( apiFetch.defaultFetchHandler ) + ); } /** @@ -545,6 +558,146 @@ function isRestIndexPath( path ) { return pathname === '' || pathname === '/'; } +/** + * Wraps a fetch handler to retry read-only requests the site rate-limits. + * + * core-data caches some failed resolutions for the session, so one throttled + * request in the editor's load burst can break a block until reload. As a + * fetch handler, it also retries each page `fetchAllMiddleware` requests. + * + * Exported for testing only. + * + * @param {FetchHandler} fetchHandler The handler performing the request. + * @return {FetchHandler} The handler with retries. + */ +export function withRateLimitRetry( fetchHandler ) { + return async ( options ) => { + const method = ( options.method ?? 'GET' ).toUpperCase(); + if ( ! RETRYABLE_METHODS.includes( method ) ) { + return fetchHandler( options ); + } + + for ( let attempt = 0; ; attempt++ ) { + let response; + let isOk = true; + try { + response = await fetchHandler( { ...options, parse: false } ); + } catch ( err ) { + // Network, offline, and abort errors have no response. + if ( typeof err?.status !== 'number' ) { + throw err; + } + response = err; + isOk = false; + } + + if ( response.status === 429 && ! options.signal?.aborted ) { + const request = `${ method } ${ options.url ?? options.path }`; + if ( attempt < RETRY_DELAYS_MS.length ) { + const delay = getRetryDelay( response, attempt ); + // A retry sent before a longer `Retry-After` elapses would fail too. + if ( delay <= MAX_RETRY_AFTER_MS ) { + info( `Retrying ${ request } after a 429 response` ); + await wait( delay, options.signal ); + continue; + } + } + // core-data may cache this failure for the session. + warn( `Giving up on ${ request } after a 429 response`, { + retries: attempt, + } ); + } + + if ( options.parse === false ) { + if ( ! isOk ) { + throw response; + } + return response; + } + return parseResponse( response, isOk ); + } + }; +} + +/** + * Returns how long to wait before retrying a rate-limited request. + * + * @param {Response} response The 429 response. + * @param {number} attempt Zero-based index of the retry about to happen. + * @return {number} Delay in milliseconds. + */ +function getRetryDelay( response, attempt ) { + const retryAfter = response.headers?.get?.( 'retry-after' ); + if ( retryAfter ) { + const seconds = Number( retryAfter ); + const delay = Number.isNaN( seconds ) + ? Date.parse( retryAfter ) - Date.now() + : seconds * 1000; + if ( ! Number.isNaN( delay ) ) { + return Math.max( delay, 0 ); + } + } + + // Jitter spreads out requests that were throttled in the same burst. + const delay = RETRY_DELAYS_MS[ attempt ]; + return delay + Math.random() * delay; +} + +/** + * Resolves after the given delay, or rejects as `fetch` would once aborted. + * + * @param {number} ms Delay in milliseconds. + * @param {AbortSignal} [signal] Signal of the request being retried. + * @return {Promise} Resolves once the delay has elapsed. + */ +function wait( ms, signal ) { + return new Promise( ( resolve, reject ) => { + const onAbort = () => { + clearTimeout( timer ); + reject( signal.reason ); + }; + const timer = setTimeout( () => { + signal?.removeEventListener( 'abort', onAbort ); + resolve(); + }, ms ); + signal?.addEventListener( 'abort', onAbort, { once: true } ); + } ); +} + +/** + * Parses a response the way api-fetch's default handler does, which does not + * expose its parsing. + * + * @param {Response} response The response. + * @param {boolean} isOk Whether the handler accepted the response. + * @return {Promise} The parsed body; rejects with it for an error response. + */ +async function parseResponse( response, isOk ) { + if ( isOk && response.status === 204 ) { + return null; + } + + let body; + try { + if ( typeof response.text !== 'function' ) { + body = await response.json(); + } else { + const text = await response.text(); + body = isOk && text === '' ? null : JSON.parse( text ); + } + } catch { + throw { + code: 'invalid_json', + message: __( 'The response is not a valid JSON response.' ), + }; + } + + if ( ! isOk ) { + throw body; + } + return body; +} + const defaultPreloadData = { '/wp/v2/types?context=view': { body: { diff --git a/src/utils/api-fetch.test.js b/src/utils/api-fetch.test.js index 5de83e53c..a9b24fc9a 100644 --- a/src/utils/api-fetch.test.js +++ b/src/utils/api-fetch.test.js @@ -8,8 +8,9 @@ import { vi, } from 'vitest'; import apiFetch from '@wordpress/api-fetch'; -import { configureApiFetch } from './api-fetch'; +import { configureApiFetch, withRateLimitRetry } from './api-fetch'; import * as bridge from './bridge'; +import * as logger from './logger'; vi.mock( './bridge', async ( importOriginal ) => { const actual = await importOriginal(); @@ -18,6 +19,7 @@ vi.mock( './bridge', async ( importOriginal ) => { getGBKit: vi.fn(), }; } ); +vi.mock( './logger' ); describe( 'api-fetch credentials handling', () => { let originalFetch; @@ -337,6 +339,382 @@ describe( 'api-fetch credentials handling', () => { ); } ); + describe( 'withRateLimitRetry', () => { + const rateLimited = ( headers = {} ) => + Promise.resolve( + new Response( 'Too Many Requests', { + status: 429, + headers: { 'Content-Type': 'text/html', ...headers }, + } ) + ); + const okResponse = () => + Promise.resolve( new Response( '{"ok":true}', { status: 200 } ) ); + // Responses are compared by status, as their bodies are single-use. + const settle = ( promise ) => + promise.then( + ( value ) => ( { resolved: summarize( value ) } ), + ( err ) => ( { rejected: summarize( err ) } ) + ); + const summarize = ( value ) => + value instanceof Response ? { status: value.status } : value; + + beforeEach( () => { + bridge.getGBKit.mockReturnValue( { + siteApiRoot: 'https://example.com/wp-json/', + siteApiNamespace: [], + namespaceExcludedPaths: [], + } ); + vi.useFakeTimers(); + vi.spyOn( Math, 'random' ).mockReturnValue( 0 ); + } ); + + afterEach( () => { + vi.useRealTimers(); + vi.restoreAllMocks(); + } ); + + it.each( [ 'GET', 'HEAD', 'OPTIONS' ] )( + 'retries a rate-limited %s request', + async ( method ) => { + global.fetch = vi + .fn() + .mockImplementationOnce( () => rateLimited() ) + .mockImplementationOnce( okResponse ); + + const request = apiFetch( { + path: '/wp/v2/taxonomies', + method, + parse: false, + } ); + await vi.runAllTimersAsync(); + + expect( ( await request ).status ).toBe( 200 ); + expect( global.fetch ).toHaveBeenCalledTimes( 2 ); + } + ); + + it( 'resolves with the parsed body of the retried request', async () => { + global.fetch = vi + .fn() + .mockImplementationOnce( () => rateLimited() ) + .mockImplementationOnce( okResponse ); + + const request = apiFetch( { path: '/wp/v2/taxonomies' } ); + await vi.runAllTimersAsync(); + + expect( await request ).toEqual( { ok: true } ); + } ); + + it( 'logs each retry as info rather than a warning', async () => { + global.fetch = vi + .fn() + .mockImplementationOnce( () => rateLimited() ) + .mockImplementationOnce( okResponse ); + + const request = apiFetch( { path: '/wp/v2/taxonomies' } ); + await vi.runAllTimersAsync(); + await request; + + expect( logger.info ).toHaveBeenCalledWith( + expect.stringContaining( 'Retrying GET' ) + ); + expect( logger.warn ).not.toHaveBeenCalled(); + } ); + + it( 'waits longer before each retry', async () => { + global.fetch = vi + .fn() + .mockImplementationOnce( () => rateLimited() ) + .mockImplementationOnce( () => rateLimited() ) + .mockImplementationOnce( okResponse ); + + const request = apiFetch( { path: '/wp/v2/taxonomies' } ); + + await vi.advanceTimersByTimeAsync( 499 ); + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + await vi.advanceTimersByTimeAsync( 1 ); + expect( global.fetch ).toHaveBeenCalledTimes( 2 ); + await vi.advanceTimersByTimeAsync( 1999 ); + expect( global.fetch ).toHaveBeenCalledTimes( 2 ); + await vi.advanceTimersByTimeAsync( 1 ); + + expect( await request ).toEqual( { ok: true } ); + expect( global.fetch ).toHaveBeenCalledTimes( 3 ); + } ); + + it( 'adds jitter in proportion to each base delay', async () => { + vi.spyOn( Math, 'random' ).mockReturnValue( 0.5 ); + global.fetch = vi + .fn() + .mockImplementationOnce( () => rateLimited() ) + .mockImplementationOnce( () => rateLimited() ) + .mockImplementationOnce( okResponse ); + + const request = apiFetch( { path: '/wp/v2/taxonomies' } ); + + await vi.advanceTimersByTimeAsync( 749 ); + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + await vi.advanceTimersByTimeAsync( 1 ); + expect( global.fetch ).toHaveBeenCalledTimes( 2 ); + await vi.advanceTimersByTimeAsync( 2999 ); + expect( global.fetch ).toHaveBeenCalledTimes( 2 ); + await vi.advanceTimersByTimeAsync( 1 ); + + expect( await request ).toEqual( { ok: true } ); + expect( global.fetch ).toHaveBeenCalledTimes( 3 ); + } ); + + it( 'waits as long as the Retry-After header asks', async () => { + global.fetch = vi + .fn() + .mockImplementationOnce( () => + rateLimited( { 'Retry-After': '3' } ) + ) + .mockImplementationOnce( okResponse ); + + const request = apiFetch( { path: '/wp/v2/taxonomies' } ); + + await vi.advanceTimersByTimeAsync( 2999 ); + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + await vi.advanceTimersByTimeAsync( 1 ); + + expect( await request ).toEqual( { ok: true } ); + } ); + + it( 'waits until the date the Retry-After header names', async () => { + vi.setSystemTime( new Date( '2026-01-01T00:00:00Z' ) ); + global.fetch = vi + .fn() + .mockImplementationOnce( () => + rateLimited( { + 'Retry-After': 'Thu, 01 Jan 2026 00:00:03 GMT', + } ) + ) + .mockImplementationOnce( okResponse ); + + const request = apiFetch( { path: '/wp/v2/taxonomies' } ); + + await vi.advanceTimersByTimeAsync( 2999 ); + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + await vi.advanceTimersByTimeAsync( 1 ); + + expect( await request ).toEqual( { ok: true } ); + } ); + + it( 'waits for a Retry-After delay of up to 10 seconds', async () => { + global.fetch = vi + .fn() + .mockImplementationOnce( () => + rateLimited( { 'Retry-After': '10' } ) + ) + .mockImplementationOnce( okResponse ); + + const request = apiFetch( { path: '/wp/v2/taxonomies' } ); + + await vi.advanceTimersByTimeAsync( 9_999 ); + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + await vi.advanceTimersByTimeAsync( 1 ); + + expect( await request ).toEqual( { ok: true } ); + } ); + + it( 'does not retry when Retry-After asks for a longer delay', async () => { + global.fetch = vi.fn( () => + rateLimited( { 'Retry-After': '11' } ) + ); + + const request = apiFetch( { path: '/wp/v2/taxonomies' } ); + const assertion = expect( request ).rejects.toMatchObject( { + code: 'invalid_json', + } ); + await vi.runAllTimersAsync(); + + await assertion; + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + expect( logger.warn ).toHaveBeenCalledWith( + expect.stringContaining( 'Giving up on GET' ), + { retries: 0 } + ); + } ); + + it( 'rejects as api-fetch would once retries run out', async () => { + global.fetch = vi.fn( () => rateLimited() ); + + const request = apiFetch( { path: '/wp/v2/taxonomies' } ); + const assertion = expect( request ).rejects.toMatchObject( { + code: 'invalid_json', + } ); + await vi.runAllTimersAsync(); + + await assertion; + expect( global.fetch ).toHaveBeenCalledTimes( 3 ); + expect( logger.warn ).toHaveBeenCalledWith( + expect.stringContaining( 'Giving up on GET' ), + { retries: 2 } + ); + } ); + + it( 'rejects with the response once retries run out without parsing', async () => { + global.fetch = vi.fn( () => rateLimited() ); + + const request = apiFetch( { + path: '/wp/v2/taxonomies', + parse: false, + } ); + const assertion = expect( request ).rejects.toMatchObject( { + status: 429, + } ); + await vi.runAllTimersAsync(); + + await assertion; + } ); + + it( 'does not retry a request that changes server state', async () => { + global.fetch = vi.fn( () => rateLimited() ); + + const request = apiFetch( { + path: '/wp/v2/posts', + method: 'POST', + data: {}, + } ); + const assertion = expect( request ).rejects.toMatchObject( { + code: 'invalid_json', + } ); + await vi.runAllTimersAsync(); + + await assertion; + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'does not retry other error responses', async () => { + global.fetch = vi.fn( () => + Promise.resolve( + new Response( '{"code":"rest_forbidden"}', { + status: 403, + } ) + ) + ); + + const request = apiFetch( { path: '/wp/v2/taxonomies' } ); + const assertion = expect( request ).rejects.toMatchObject( { + code: 'rest_forbidden', + } ); + await vi.runAllTimersAsync(); + + await assertion; + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'does not retry network errors', async () => { + global.fetch = vi.fn( () => + Promise.reject( new TypeError( 'Failed to fetch' ) ) + ); + + const request = apiFetch( { path: '/wp/v2/taxonomies' } ); + const assertion = expect( request ).rejects.toMatchObject( { + code: 'fetch_error', + } ); + await vi.runAllTimersAsync(); + + await assertion; + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'does not retry an aborted request', async () => { + const controller = new AbortController(); + global.fetch = vi.fn( () => { + controller.abort(); + return rateLimited(); + } ); + + const request = apiFetch( { + path: '/wp/v2/taxonomies', + signal: controller.signal, + } ); + const assertion = expect( request ).rejects.toMatchObject( { + code: 'invalid_json', + } ); + await vi.runAllTimersAsync(); + + await assertion; + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + expect( logger.warn ).not.toHaveBeenCalled(); + } ); + + it( 'rejects at once when aborted while waiting to retry', async () => { + const controller = new AbortController(); + global.fetch = vi.fn( () => rateLimited() ); + + const request = apiFetch( { + path: '/wp/v2/taxonomies', + signal: controller.signal, + } ); + const assertion = expect( request ).rejects.toMatchObject( { + name: 'AbortError', + } ); + await vi.advanceTimersByTimeAsync( 100 ); + controller.abort(); + await vi.advanceTimersByTimeAsync( 0 ); + + expect( vi.getTimerCount() ).toBe( 0 ); + await assertion; + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + } ); + + it.each( [ + [ 'a 204 response', new Response( null, { status: 204 } ), null ], + [ 'an empty body', new Response( '', { status: 200 } ), null ], + ] )( 'resolves with null for %s', async ( _label, response, body ) => { + global.fetch = vi.fn( () => Promise.resolve( response ) ); + + expect( await apiFetch( { path: '/wp/v2/taxonomies' } ) ).toBe( + body + ); + } ); + + it( 'rejects invalid JSON in a successful response', async () => { + global.fetch = vi.fn( () => + Promise.resolve( new Response( '', { status: 200 } ) ) + ); + + await expect( + apiFetch( { path: '/wp/v2/taxonomies' } ) + ).rejects.toMatchObject( { code: 'invalid_json' } ); + } ); + + // The wrapper parses responses itself, so guard against drifting from + // api-fetch's own parsing when the package updates. + describe.each( [ true, false ] )( 'with parse: %s', ( parse ) => { + it.each( [ + [ 'a JSON body', 200, '{"id":1}' ], + [ 'an empty body', 200, '' ], + [ 'invalid JSON', 200, '' ], + [ 'a 204 response', 204, null ], + [ 'a JSON error', 404, '{"code":"rest_no_route"}' ], + [ 'an empty error body', 404, '' ], + [ 'an HTML error', 500, 'Error' ], + ] )( + 'settles %s as api-fetch does', + async ( _label, status, body ) => { + global.fetch = vi.fn( () => + Promise.resolve( new Response( body, { status } ) ) + ); + const options = { + url: 'https://example.com/wp-json/wp/v2/taxonomies', + parse, + }; + const handler = withRateLimitRetry( + apiFetch.defaultFetchHandler + ); + + expect( await settle( handler( options ) ) ).toEqual( + await settle( apiFetch.defaultFetchHandler( options ) ) + ); + } + ); + } ); + } ); + it( 'should preserve other headers when adding Authorization', async () => { bridge.getGBKit.mockReturnValue( { siteApiRoot: 'https://example.com/wp-json/',