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
8 changes: 7 additions & 1 deletion packages/b2c-tooling-sdk/src/clients/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
257 changes: 257 additions & 0 deletions packages/b2c-tooling-sdk/src/clients/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>;
}

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<boolean> {
if (ms <= 0) {
return true;
}

if (signal?.aborted) {
return false;
}

await new Promise<void>((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<Response>}).fetch;
// Only handle configured status codes
if (!statusCodes.includes(response.status) || maxRetries <= 0) {
return response;
}

const fetchFn: ((request: Request) => Promise<Response>) | 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.
*/
Expand Down
9 changes: 8 additions & 1 deletion packages/b2c-tooling-sdk/src/clients/mrt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading