Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
261 changes: 261 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,267 @@ 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);
} else {
delayMs = Math.min(delayMs, maxDelayMs);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Math.min here seems to imply we might not always respect Retry-After headers from MRT. I think it's important we do.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I updated to always respect Retry-After and apply maxDelayMs only when needed

}

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);
} else {
delayMs = Math.min(delayMs, 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 @@ -149,6 +149,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