@@ -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 */
0 commit comments