Skip to content

Commit 05fd75d

Browse files
authored
adding rate limiting middleware (#104)
1 parent 901fe40 commit 05fd75d

File tree

4 files changed

+604
-2
lines changed

4 files changed

+604
-2
lines changed

packages/b2c-tooling-sdk/src/clients/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,17 @@ export type {PropfindEntry, WebDavClientOptions} from './webdav.js';
120120

121121
export {
122122
createAuthMiddleware,
123+
createRateLimitMiddleware,
123124
createLoggingMiddleware,
124125
createExtraParamsMiddleware,
125126
createUserAgentMiddleware,
126127
} from './middleware.js';
127-
export type {ExtraParamsConfig, LoggingMiddlewareConfig, UserAgentConfig} from './middleware.js';
128+
export type {
129+
ExtraParamsConfig,
130+
LoggingMiddlewareConfig,
131+
UserAgentConfig,
132+
RateLimitMiddlewareConfig,
133+
} from './middleware.js';
128134

129135
// User-Agent provider (auto-registers on import)
130136
export {setUserAgent, getUserAgent, resetUserAgent, userAgentProvider} from './user-agent.js';

packages/b2c-tooling-sdk/src/clients/middleware.ts

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,263 @@ export function createAuthMiddleware(auth: AuthStrategy): Middleware {
5959
};
6060
}
6161

62+
/**
63+
* Configuration for rate limiting middleware.
64+
*/
65+
export interface RateLimitMiddlewareConfig {
66+
/**
67+
* Maximum number of retry attempts when a rate limit response is received.
68+
* Defaults to 3.
69+
*/
70+
maxRetries?: number;
71+
72+
/**
73+
* Base delay in milliseconds used for exponential backoff when no Retry-After
74+
* header is present. Defaults to 1000ms.
75+
*/
76+
baseDelayMs?: number;
77+
78+
/**
79+
* Maximum delay in milliseconds between retries. Defaults to 30000ms.
80+
*/
81+
maxDelayMs?: number;
82+
83+
/**
84+
* HTTP status codes that should trigger rate limit handling.
85+
* Defaults to [429]. 503 is often used for overload, but is not included
86+
* by default to avoid surprising retries for maintenance windows.
87+
*/
88+
statusCodes?: number[];
89+
90+
/**
91+
* Optional log prefix (e.g., 'MRT') used in log messages.
92+
*/
93+
prefix?: string;
94+
95+
/**
96+
* Optional fetch implementation used for retries when the middleware context
97+
* does not provide a re-dispatch helper.
98+
*/
99+
fetch?: (request: Request) => Promise<Response>;
100+
}
101+
102+
const DEFAULT_RATE_LIMIT_MAX_RETRIES = 3;
103+
const DEFAULT_RATE_LIMIT_BASE_DELAY_MS = 1000;
104+
const DEFAULT_RATE_LIMIT_MAX_DELAY_MS = 30000;
105+
const DEFAULT_RATE_LIMIT_STATUS_CODES = [429];
106+
const DEFAULT_RATE_LIMIT_JITTER_RATIO = 0.2;
107+
108+
async function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<boolean> {
109+
if (ms <= 0) {
110+
return true;
111+
}
112+
113+
if (signal?.aborted) {
114+
return false;
115+
}
116+
117+
await new Promise<void>((resolve) => {
118+
function onAbort() {
119+
clearTimeout(timeout);
120+
signal?.removeEventListener('abort', onAbort);
121+
resolve();
122+
}
123+
124+
const timeout = setTimeout(() => {
125+
signal?.removeEventListener('abort', onAbort);
126+
resolve();
127+
}, ms);
128+
129+
signal?.addEventListener('abort', onAbort);
130+
});
131+
132+
return !signal?.aborted;
133+
}
134+
135+
/**
136+
* Parses the Retry-After header into a delay in milliseconds.
137+
* Supports both seconds and HTTP date formats. Returns undefined if
138+
* the header is missing or invalid.
139+
*/
140+
function parseRetryAfter(headerValue: string | null): number | undefined {
141+
if (!headerValue) {
142+
return undefined;
143+
}
144+
145+
const seconds = Number(headerValue);
146+
if (!Number.isNaN(seconds)) {
147+
return Math.max(0, Math.round(seconds * 1000));
148+
}
149+
150+
const dateMs = Date.parse(headerValue);
151+
if (!Number.isNaN(dateMs)) {
152+
const diff = dateMs - Date.now();
153+
return diff > 0 ? diff : 0;
154+
}
155+
156+
return undefined;
157+
}
158+
159+
/**
160+
* Returns the next backoff delay based on attempt count.
161+
*/
162+
function computeBackoffDelayMs(attempt: number, baseDelayMs: number, maxDelayMs: number): number {
163+
const delay = baseDelayMs * Math.pow(2, Math.max(0, attempt));
164+
if (delay <= 0) {
165+
return 0;
166+
}
167+
168+
const jitter = Math.round(delay * DEFAULT_RATE_LIMIT_JITTER_RATIO * Math.random());
169+
return Math.min(delay + jitter, maxDelayMs);
170+
}
171+
172+
/**
173+
* Creates rate limiting middleware for openapi-fetch clients.
174+
*
175+
* This middleware inspects responses for rate-limit status codes (by default
176+
* 429 Too Many Requests), uses the Retry-After header when present to
177+
* determine a delay, and retries the request up to a configurable limit.
178+
*
179+
* The middleware is generic and can be used by MRT and other clients. It does
180+
* not currently read CLI configuration directly; callers should pass
181+
* configuration via the factory function.
182+
*/
183+
export function createRateLimitMiddleware(config: RateLimitMiddlewareConfig = {}): Middleware {
184+
const logger = getLogger();
185+
const {
186+
maxRetries = DEFAULT_RATE_LIMIT_MAX_RETRIES,
187+
baseDelayMs = DEFAULT_RATE_LIMIT_BASE_DELAY_MS,
188+
maxDelayMs = DEFAULT_RATE_LIMIT_MAX_DELAY_MS,
189+
statusCodes = DEFAULT_RATE_LIMIT_STATUS_CODES,
190+
prefix,
191+
fetch: configFetch,
192+
} = config;
193+
194+
const tag = prefix ? `[${prefix} RATE]` : '[RATE]';
195+
196+
return {
197+
async onResponse(ctx) {
198+
const {request, response} = ctx;
199+
const ctxFetch = (ctx as {fetch?: (request: Request) => Promise<Response>}).fetch;
200+
// Only handle configured status codes
201+
if (!statusCodes.includes(response.status) || maxRetries <= 0) {
202+
return response;
203+
}
204+
205+
const fetchFn: ((request: Request) => Promise<Response>) | undefined =
206+
ctxFetch ?? configFetch ?? (typeof fetch === 'function' ? fetch : undefined);
207+
208+
if (!fetchFn) {
209+
return response;
210+
}
211+
212+
const reqWithAttempt = request as Request & {_rateLimitAttempt?: number};
213+
const startingAttempt = reqWithAttempt._rateLimitAttempt ?? 0;
214+
215+
// If openapi-fetch provides ctx.fetch, it typically re-enters the middleware chain.
216+
// In that case, do a single retry and let subsequent attempts be handled by
217+
// subsequent middleware invocations (guarded by _rateLimitAttempt).
218+
if (ctxFetch) {
219+
if (startingAttempt >= maxRetries) {
220+
logger.debug(
221+
{status: response.status, attempt: startingAttempt, maxRetries},
222+
`${tag} Max retries reached, not retrying request`,
223+
);
224+
return response;
225+
}
226+
227+
const retryAfterHeader = response.headers.get('Retry-After');
228+
let delayMs = parseRetryAfter(retryAfterHeader);
229+
230+
if (delayMs === undefined) {
231+
delayMs = computeBackoffDelayMs(startingAttempt, baseDelayMs, maxDelayMs);
232+
}
233+
234+
logger.warn(
235+
{
236+
status: response.status,
237+
attempt: startingAttempt + 1,
238+
maxRetries,
239+
delayMs,
240+
retryAfter: retryAfterHeader ?? undefined,
241+
url: request.url,
242+
},
243+
`${tag} Rate limit encountered, retrying request after ${delayMs}ms (attempt ${
244+
startingAttempt + 1
245+
}/${maxRetries})`,
246+
);
247+
248+
const canRetry = await sleepWithAbort(delayMs, request.signal);
249+
if (!canRetry) {
250+
return response;
251+
}
252+
253+
reqWithAttempt._rateLimitAttempt = startingAttempt + 1;
254+
255+
let retryRequest = request;
256+
try {
257+
retryRequest = request.clone();
258+
} catch {
259+
logger.debug({url: request.url}, `${tag} Could not clone request for retry; retrying with original request`);
260+
}
261+
262+
return fetchFn(retryRequest);
263+
}
264+
265+
// Fallback path: if ctx.fetch is not provided, handle retries in this invocation.
266+
let lastResponse = response;
267+
let attempt = startingAttempt;
268+
269+
while (statusCodes.includes(lastResponse.status) && attempt < maxRetries) {
270+
const retryAfterHeader = lastResponse.headers.get('Retry-After');
271+
let delayMs = parseRetryAfter(retryAfterHeader);
272+
273+
if (delayMs === undefined) {
274+
delayMs = computeBackoffDelayMs(attempt, baseDelayMs, maxDelayMs);
275+
}
276+
277+
logger.warn(
278+
{
279+
status: lastResponse.status,
280+
attempt: attempt + 1,
281+
maxRetries,
282+
delayMs,
283+
retryAfter: retryAfterHeader ?? undefined,
284+
url: request.url,
285+
},
286+
`${tag} Rate limit encountered, retrying request after ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`,
287+
);
288+
289+
const canRetry = await sleepWithAbort(delayMs, request.signal);
290+
if (!canRetry) {
291+
return lastResponse;
292+
}
293+
294+
attempt += 1;
295+
reqWithAttempt._rateLimitAttempt = attempt;
296+
297+
let retryRequest = request;
298+
try {
299+
retryRequest = request.clone();
300+
} catch {
301+
logger.debug({url: request.url}, `${tag} Could not clone request for retry; retrying with original request`);
302+
}
303+
304+
lastResponse = await fetchFn(retryRequest);
305+
}
306+
307+
if (statusCodes.includes(lastResponse.status) && attempt >= maxRetries) {
308+
logger.debug(
309+
{status: lastResponse.status, attempt, maxRetries},
310+
`${tag} Max retries reached, not retrying request`,
311+
);
312+
}
313+
314+
return lastResponse;
315+
},
316+
};
317+
}
318+
62319
/**
63320
* Configuration for logging middleware.
64321
*/

packages/b2c-tooling-sdk/src/clients/mrt.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import createClient, {type Client} from 'openapi-fetch';
1616
import type {AuthStrategy} from '../auth/types.js';
1717
import type {paths, components} from './mrt.generated.js';
18-
import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js';
18+
import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js';
1919
import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js';
2020

2121
/**
@@ -172,6 +172,13 @@ export function createMrtClient(config: MrtClientConfig, auth: AuthStrategy): Mr
172172
client.use(middleware);
173173
}
174174

175+
// Rate limiting middleware (retries on 429 using Retry-After/header-based backoff)
176+
client.use(
177+
createRateLimitMiddleware({
178+
prefix: 'MRT',
179+
}),
180+
);
181+
175182
// Logging middleware last (sees complete request with all modifications)
176183
client.use(
177184
createLoggingMiddleware({

0 commit comments

Comments
 (0)