diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 6bc6978f..669dcdff 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -120,11 +120,17 @@ export type {PropfindEntry, WebDavClientOptions} from './webdav.js'; export { createAuthMiddleware, + createRateLimitMiddleware, createLoggingMiddleware, createExtraParamsMiddleware, createUserAgentMiddleware, } from './middleware.js'; -export type {ExtraParamsConfig, LoggingMiddlewareConfig, UserAgentConfig} from './middleware.js'; +export type { + ExtraParamsConfig, + LoggingMiddlewareConfig, + UserAgentConfig, + RateLimitMiddlewareConfig, +} from './middleware.js'; // User-Agent provider (auto-registers on import) export {setUserAgent, getUserAgent, resetUserAgent, userAgentProvider} from './user-agent.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/middleware.ts b/packages/b2c-tooling-sdk/src/clients/middleware.ts index b8dadddb..9c2d63b0 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware.ts @@ -59,6 +59,263 @@ export function createAuthMiddleware(auth: AuthStrategy): Middleware { }; } +/** + * Configuration for rate limiting middleware. + */ +export interface RateLimitMiddlewareConfig { + /** + * Maximum number of retry attempts when a rate limit response is received. + * Defaults to 3. + */ + maxRetries?: number; + + /** + * Base delay in milliseconds used for exponential backoff when no Retry-After + * header is present. Defaults to 1000ms. + */ + baseDelayMs?: number; + + /** + * Maximum delay in milliseconds between retries. Defaults to 30000ms. + */ + maxDelayMs?: number; + + /** + * HTTP status codes that should trigger rate limit handling. + * Defaults to [429]. 503 is often used for overload, but is not included + * by default to avoid surprising retries for maintenance windows. + */ + statusCodes?: number[]; + + /** + * Optional log prefix (e.g., 'MRT') used in log messages. + */ + prefix?: string; + + /** + * Optional fetch implementation used for retries when the middleware context + * does not provide a re-dispatch helper. + */ + fetch?: (request: Request) => Promise; +} + +const DEFAULT_RATE_LIMIT_MAX_RETRIES = 3; +const DEFAULT_RATE_LIMIT_BASE_DELAY_MS = 1000; +const DEFAULT_RATE_LIMIT_MAX_DELAY_MS = 30000; +const DEFAULT_RATE_LIMIT_STATUS_CODES = [429]; +const DEFAULT_RATE_LIMIT_JITTER_RATIO = 0.2; + +async function sleepWithAbort(ms: number, signal?: AbortSignal): Promise { + if (ms <= 0) { + return true; + } + + if (signal?.aborted) { + return false; + } + + await new Promise((resolve) => { + function onAbort() { + clearTimeout(timeout); + signal?.removeEventListener('abort', onAbort); + resolve(); + } + + const timeout = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + + signal?.addEventListener('abort', onAbort); + }); + + return !signal?.aborted; +} + +/** + * Parses the Retry-After header into a delay in milliseconds. + * Supports both seconds and HTTP date formats. Returns undefined if + * the header is missing or invalid. + */ +function parseRetryAfter(headerValue: string | null): number | undefined { + if (!headerValue) { + return undefined; + } + + const seconds = Number(headerValue); + if (!Number.isNaN(seconds)) { + return Math.max(0, Math.round(seconds * 1000)); + } + + const dateMs = Date.parse(headerValue); + if (!Number.isNaN(dateMs)) { + const diff = dateMs - Date.now(); + return diff > 0 ? diff : 0; + } + + return undefined; +} + +/** + * Returns the next backoff delay based on attempt count. + */ +function computeBackoffDelayMs(attempt: number, baseDelayMs: number, maxDelayMs: number): number { + const delay = baseDelayMs * Math.pow(2, Math.max(0, attempt)); + if (delay <= 0) { + return 0; + } + + const jitter = Math.round(delay * DEFAULT_RATE_LIMIT_JITTER_RATIO * Math.random()); + return Math.min(delay + jitter, maxDelayMs); +} + +/** + * Creates rate limiting middleware for openapi-fetch clients. + * + * This middleware inspects responses for rate-limit status codes (by default + * 429 Too Many Requests), uses the Retry-After header when present to + * determine a delay, and retries the request up to a configurable limit. + * + * The middleware is generic and can be used by MRT and other clients. It does + * not currently read CLI configuration directly; callers should pass + * configuration via the factory function. + */ +export function createRateLimitMiddleware(config: RateLimitMiddlewareConfig = {}): Middleware { + const logger = getLogger(); + const { + maxRetries = DEFAULT_RATE_LIMIT_MAX_RETRIES, + baseDelayMs = DEFAULT_RATE_LIMIT_BASE_DELAY_MS, + maxDelayMs = DEFAULT_RATE_LIMIT_MAX_DELAY_MS, + statusCodes = DEFAULT_RATE_LIMIT_STATUS_CODES, + prefix, + fetch: configFetch, + } = config; + + const tag = prefix ? `[${prefix} RATE]` : '[RATE]'; + + return { + async onResponse(ctx) { + const {request, response} = ctx; + const ctxFetch = (ctx as {fetch?: (request: Request) => Promise}).fetch; + // Only handle configured status codes + if (!statusCodes.includes(response.status) || maxRetries <= 0) { + return response; + } + + const fetchFn: ((request: Request) => Promise) | undefined = + ctxFetch ?? configFetch ?? (typeof fetch === 'function' ? fetch : undefined); + + if (!fetchFn) { + return response; + } + + const reqWithAttempt = request as Request & {_rateLimitAttempt?: number}; + const startingAttempt = reqWithAttempt._rateLimitAttempt ?? 0; + + // If openapi-fetch provides ctx.fetch, it typically re-enters the middleware chain. + // In that case, do a single retry and let subsequent attempts be handled by + // subsequent middleware invocations (guarded by _rateLimitAttempt). + if (ctxFetch) { + if (startingAttempt >= maxRetries) { + logger.debug( + {status: response.status, attempt: startingAttempt, maxRetries}, + `${tag} Max retries reached, not retrying request`, + ); + return response; + } + + const retryAfterHeader = response.headers.get('Retry-After'); + let delayMs = parseRetryAfter(retryAfterHeader); + + if (delayMs === undefined) { + delayMs = computeBackoffDelayMs(startingAttempt, baseDelayMs, maxDelayMs); + } + + logger.warn( + { + status: response.status, + attempt: startingAttempt + 1, + maxRetries, + delayMs, + retryAfter: retryAfterHeader ?? undefined, + url: request.url, + }, + `${tag} Rate limit encountered, retrying request after ${delayMs}ms (attempt ${ + startingAttempt + 1 + }/${maxRetries})`, + ); + + const canRetry = await sleepWithAbort(delayMs, request.signal); + if (!canRetry) { + return response; + } + + reqWithAttempt._rateLimitAttempt = startingAttempt + 1; + + let retryRequest = request; + try { + retryRequest = request.clone(); + } catch { + logger.debug({url: request.url}, `${tag} Could not clone request for retry; retrying with original request`); + } + + return fetchFn(retryRequest); + } + + // Fallback path: if ctx.fetch is not provided, handle retries in this invocation. + let lastResponse = response; + let attempt = startingAttempt; + + while (statusCodes.includes(lastResponse.status) && attempt < maxRetries) { + const retryAfterHeader = lastResponse.headers.get('Retry-After'); + let delayMs = parseRetryAfter(retryAfterHeader); + + if (delayMs === undefined) { + delayMs = computeBackoffDelayMs(attempt, baseDelayMs, maxDelayMs); + } + + logger.warn( + { + status: lastResponse.status, + attempt: attempt + 1, + maxRetries, + delayMs, + retryAfter: retryAfterHeader ?? undefined, + url: request.url, + }, + `${tag} Rate limit encountered, retrying request after ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`, + ); + + const canRetry = await sleepWithAbort(delayMs, request.signal); + if (!canRetry) { + return lastResponse; + } + + attempt += 1; + reqWithAttempt._rateLimitAttempt = attempt; + + let retryRequest = request; + try { + retryRequest = request.clone(); + } catch { + logger.debug({url: request.url}, `${tag} Could not clone request for retry; retrying with original request`); + } + + lastResponse = await fetchFn(retryRequest); + } + + if (statusCodes.includes(lastResponse.status) && attempt >= maxRetries) { + logger.debug( + {status: lastResponse.status, attempt, maxRetries}, + `${tag} Max retries reached, not retrying request`, + ); + } + + return lastResponse; + }, + }; +} + /** * Configuration for logging middleware. */ diff --git a/packages/b2c-tooling-sdk/src/clients/mrt.ts b/packages/b2c-tooling-sdk/src/clients/mrt.ts index 27ec15ea..18520eb3 100644 --- a/packages/b2c-tooling-sdk/src/clients/mrt.ts +++ b/packages/b2c-tooling-sdk/src/clients/mrt.ts @@ -15,7 +15,7 @@ import createClient, {type Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; import type {paths, components} from './mrt.generated.js'; -import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; +import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; /** @@ -172,6 +172,13 @@ export function createMrtClient(config: MrtClientConfig, auth: AuthStrategy): Mr client.use(middleware); } + // Rate limiting middleware (retries on 429 using Retry-After/header-based backoff) + client.use( + createRateLimitMiddleware({ + prefix: 'MRT', + }), + ); + // Logging middleware last (sees complete request with all modifications) client.use( createLoggingMiddleware({ diff --git a/packages/b2c-tooling-sdk/test/clients/middleware.test.ts b/packages/b2c-tooling-sdk/test/clients/middleware.test.ts index 8fc63c28..3cd8608d 100644 --- a/packages/b2c-tooling-sdk/test/clients/middleware.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/middleware.test.ts @@ -13,6 +13,7 @@ import { createAuthMiddleware, createExtraParamsMiddleware, createLoggingMiddleware, + createRateLimitMiddleware, } from '@salesforce/b2c-tooling-sdk/clients'; import type {AuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; import {configureLogger, getLogger, resetLogger} from '@salesforce/b2c-tooling-sdk/logging'; @@ -444,4 +445,335 @@ describe('clients/middleware', () => { } }); }); + + describe('createRateLimitMiddleware', () => { + it('retries once on 429 with Retry-After header', async () => { + const middleware = createRateLimitMiddleware({ + maxRetries: 1, + prefix: 'TEST', + }); + + type OnResponseParams = Parameters>[0]; + + let callCount = 0; + const request = new Request('https://example.com/rate', {method: 'GET'}); + + const firstResponse = new Response('rate-limited', { + status: 429, + headers: {'Retry-After': '0'}, + }); + + const successResponse = new Response('ok', {status: 200}); + + const fetchFn = async () => { + callCount += 1; + return successResponse; + }; + + const finalResponse = (await middleware.onResponse!({ + request, + response: firstResponse, + fetch: fetchFn, + } as unknown as OnResponseParams)) as Response; + + // ctx.fetch is only used for the retry; the initial 429 response already occurred. + expect(callCount).to.equal(1); + expect(finalResponse.status).to.equal(200); + }); + + it('retries even when Retry-After is large (Retry-After is respected by default)', async () => { + const middleware = createRateLimitMiddleware({ + maxRetries: 1, + baseDelayMs: 0, + maxDelayMs: 0, + }); + + type OnResponseParams = Parameters>[0]; + + const request = new Request('https://example.com/rate', {method: 'GET'}); + const firstResponse = new Response('rate-limited', { + status: 429, + headers: {'Retry-After': '0'}, + }); + + let callCount = 0; + const fetchFn = async () => { + callCount += 1; + return new Response('ok', {status: 200}); + }; + + const finalResponse = (await middleware.onResponse!({ + request, + response: firstResponse, + fetch: fetchFn, + } as unknown as OnResponseParams)) as Response; + + expect(callCount).to.equal(1); + expect(finalResponse.status).to.equal(200); + }); + + it('does not retry when the request is aborted', async () => { + const middleware = createRateLimitMiddleware({ + maxRetries: 2, + baseDelayMs: 1000, + maxDelayMs: 1000, + }); + + type OnResponseParams = Parameters>[0]; + + const controller = new AbortController(); + controller.abort(); + + const request = new Request('https://example.com/rate', {method: 'GET', signal: controller.signal}); + const rateLimitedResponse = new Response('rate-limited', { + status: 429, + headers: {'Retry-After': '10'}, + }); + + let callCount = 0; + const fetchFn = async () => { + callCount += 1; + return new Response('ok', {status: 200}); + }; + + const finalResponse = (await middleware.onResponse!({ + request, + response: rateLimitedResponse, + fetch: fetchFn, + } as unknown as OnResponseParams)) as Response; + + expect(callCount).to.equal(0); + expect(finalResponse.status).to.equal(429); + }); + + it('does not retry when maxRetries is 0', async () => { + const middleware = createRateLimitMiddleware({ + maxRetries: 0, + baseDelayMs: 0, + maxDelayMs: 0, + }); + + type OnResponseParams = Parameters>[0]; + + const request = new Request('https://example.com/rate', {method: 'GET'}); + const rateLimitedResponse = new Response('rate-limited', {status: 429}); + + let callCount = 0; + const fetchFn = async () => { + callCount += 1; + return new Response('ok', {status: 200}); + }; + + const finalResponse = (await middleware.onResponse!({ + request, + response: rateLimitedResponse, + fetch: fetchFn, + } as unknown as OnResponseParams)) as Response; + + expect(callCount).to.equal(0); + expect(finalResponse.status).to.equal(429); + }); + + it('does not retry when response status is not in statusCodes', async () => { + const middleware = createRateLimitMiddleware({ + maxRetries: 2, + statusCodes: [429], + baseDelayMs: 0, + maxDelayMs: 0, + }); + + type OnResponseParams = Parameters>[0]; + + const request = new Request('https://example.com/overloaded', {method: 'GET'}); + const overloadedResponse = new Response('overloaded', {status: 503}); + + let callCount = 0; + const fetchFn = async () => { + callCount += 1; + return new Response('ok', {status: 200}); + }; + + const finalResponse = (await middleware.onResponse!({ + request, + response: overloadedResponse, + fetch: fetchFn, + } as unknown as OnResponseParams)) as Response; + + expect(callCount).to.equal(0); + expect(finalResponse.status).to.equal(503); + }); + + it('retries using backoff when Retry-After header is missing', async () => { + const middleware = createRateLimitMiddleware({ + maxRetries: 1, + baseDelayMs: 0, + maxDelayMs: 0, + prefix: 'TEST', + }); + + type OnResponseParams = Parameters>[0]; + + let callCount = 0; + const request = new Request('https://example.com/rate', {method: 'GET'}); + + const firstResponse = new Response('rate-limited', { + status: 429, + }); + + const successResponse = new Response('ok', {status: 200}); + + const fetchFn = async () => { + callCount += 1; + return successResponse; + }; + + const finalResponse = (await middleware.onResponse!({ + request, + response: firstResponse, + fetch: fetchFn, + } as unknown as OnResponseParams)) as Response; + + // ctx.fetch is only used for the retry; the initial 429 response already occurred. + expect(callCount).to.equal(1); + expect(finalResponse.status).to.equal(200); + }); + + it('does not retry when openapi-fetch does not provide a fetch helper', async () => { + const middleware = createRateLimitMiddleware({ + maxRetries: 2, + baseDelayMs: 0, + maxDelayMs: 0, + }); + + type OnResponseParams = Parameters>[0]; + + const request = new Request('https://example.com/rate', {method: 'GET'}); + const rateLimitedResponse = new Response('rate-limited', {status: 429}); + + const originalFetch = (globalThis as unknown as {fetch?: unknown}).fetch; + try { + Object.defineProperty(globalThis, 'fetch', { + value: undefined, + writable: true, + configurable: true, + }); + + const finalResponse = (await middleware.onResponse!({ + request, + response: rateLimitedResponse, + } as unknown as OnResponseParams)) as Response; + + expect(finalResponse.status).to.equal(429); + } finally { + Object.defineProperty(globalThis, 'fetch', { + value: originalFetch, + writable: true, + configurable: true, + }); + } + }); + + it('stops retrying after maxRetries', async () => { + const middleware = createRateLimitMiddleware({ + maxRetries: 1, + baseDelayMs: 0, + maxDelayMs: 0, + }); + + type OnResponseParams = Parameters>[0]; + + const request = new Request('https://example.com/rate', {method: 'GET'}); + const rateLimitedResponse = new Response('rate-limited', {status: 429}); + + let callCount = 0; + const fetchFn = async () => { + callCount += 1; + return rateLimitedResponse; + }; + + // First 429 should trigger one retry + const firstResult = (await middleware.onResponse!({ + request, + response: rateLimitedResponse, + fetch: fetchFn, + } as unknown as OnResponseParams)) as Response; + + // Second 429 (after retry) should not trigger further retries + const secondResult = (await middleware.onResponse!({ + request, + response: rateLimitedResponse, + fetch: fetchFn, + } as unknown as OnResponseParams)) as Response; + + // Only the first onResponse triggers a single retry via ctx.fetch. + expect(callCount).to.equal(1); + expect(firstResult.status).to.equal(429); + expect(secondResult.status).to.equal(429); + }); + + it('retries multiple times when ctx.fetch is missing and config.fetch is provided', async () => { + type OnResponseParams = Parameters['onResponse']>>[0]; + + const request = new Request('https://example.com/rate', {method: 'GET'}); + const firstResponse = new Response('rate-limited', {status: 429}); + + let callCount = 0; + const fetchFn = async () => { + callCount += 1; + if (callCount === 1) { + return new Response('rate-limited again', {status: 429}); + } + return new Response('ok', {status: 200}); + }; + + const fallbackMiddleware = createRateLimitMiddleware({ + maxRetries: 2, + baseDelayMs: 0, + maxDelayMs: 0, + fetch: fetchFn, + }); + + const finalResponse = (await fallbackMiddleware.onResponse!({ + request, + response: firstResponse, + } as unknown as OnResponseParams)) as Response; + + expect(callCount).to.equal(2); + expect(finalResponse.status).to.equal(200); + }); + + it('does not retry in fallback path when request is aborted', async () => { + const controller = new AbortController(); + controller.abort(); + + type OnResponseParams = Parameters['onResponse']>>[0]; + + const request = new Request('https://example.com/rate', {method: 'GET', signal: controller.signal}); + const rateLimitedResponse = new Response('rate-limited', { + status: 429, + headers: {'Retry-After': '10'}, + }); + + let callCount = 0; + const fetchFn = async () => { + callCount += 1; + return new Response('ok', {status: 200}); + }; + + const fallbackMiddleware = createRateLimitMiddleware({ + maxRetries: 2, + baseDelayMs: 1000, + maxDelayMs: 1000, + fetch: fetchFn, + }); + + const finalResponse = (await fallbackMiddleware.onResponse!({ + request, + response: rateLimitedResponse, + } as unknown as OnResponseParams)) as Response; + + expect(callCount).to.equal(0); + expect(finalResponse.status).to.equal(429); + }); + }); });