diff --git a/README.md b/README.md index 8fb2472..61d4d03 100644 --- a/README.md +++ b/README.md @@ -153,10 +153,12 @@ sequence is intentional. Logging redacts common credential names as well as arbi query names added by `apiKeyAuth()`. Retries default to `GET`, `HEAD`, `PUT`, `DELETE`, and `OPTIONS`, and to statuses `408`, `425`, -`429`, `500`, `502`, `503`, and `504`. `Retry-After` is honored when present. Cloneable request -bodies are replayed with the original bytes and headers. `ReadableStream` bodies are explicitly -single-attempt so retry does not buffer an unbounded stream. If an earlier middleware has already -consumed any body, retry also sends it once and does not surface an incidental cloning error. +`429`, `500`, `502`, `503`, and `504`. Valid `Retry-After` values are honored up to +`maxRetryAfter` (60 seconds by default); malformed values use the normal backoff. Cloneable +request bodies are replayed with the original bytes and headers. `ReadableStream` bodies are +explicitly single-attempt so retry does not buffer an unbounded stream. If an earlier middleware +has already consumed any body, retry also sends it once and does not surface an incidental cloning +error. Do not include a status such as `401` in `retry()` when an upstream authentication middleware already handles that status. The outer authentication layer cannot react until retry's complete diff --git a/src/middleware.ts b/src/middleware.ts index b857ce3..dac3191 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -86,6 +86,8 @@ export interface RetryOptions { statuses?: readonly number[]; /** Computes the delay (ms) before the given retry attempt, if no `Retry-After` header is present. */ delay?: (attempt: number) => number; + /** Maximum delay (ms) accepted from `Retry-After`. Defaults to 60 seconds. */ + maxRetryAfter?: number; } /** * Middleware that retries failed requests. Retries eligible methods on network failures @@ -98,6 +100,9 @@ export function retry(options: RetryOptions = {}): Middleware { const attempts = options.attempts ?? 3; const methods = options.methods ?? ["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]; const statuses = options.statuses ?? [408, 425, 429, 500, 502, 503, 504]; + const maxRetryAfter = options.maxRetryAfter ?? 60_000; + if (!Number.isFinite(maxRetryAfter) || maxRetryAfter < 0) + throw new TypeError("maxRetryAfter must be a non-negative finite number"); return async (context, next) => { if (!methods.includes(context.request.method)) return next(context); if (context.replayableBody === false) return next(context); @@ -119,12 +124,17 @@ export function retry(options: RetryOptions = {}): Middleware { attempt++ ) { const retryAfter = "response" in result ? result.response?.headers.get("retry-after") : null; - const wait = retryAfter + const fallback = () => options.delay?.(attempt) ?? 100 * 2 ** (attempt - 2); + const parsedRetryAfter = retryAfter ? /^\d+$/.test(retryAfter) ? Number(retryAfter) * 1000 : Math.max(0, Date.parse(retryAfter) - Date.now()) - : (options.delay?.(attempt) ?? 100 * 2 ** (attempt - 2)); - if (context.deadline && Date.now() + wait >= context.deadline) break; + : undefined; + const wait = + parsedRetryAfter === undefined || !Number.isFinite(parsedRetryAfter) + ? fallback() + : Math.min(parsedRetryAfter, maxRetryAfter); + if (context.deadline !== undefined && Date.now() + wait >= context.deadline) break; const ready = await new Promise((resolve) => { const onAbort = () => { clearTimeout(timer); diff --git a/tests/fetch.test.ts b/tests/fetch.test.ts index aa72ed4..22ea7b1 100644 --- a/tests/fetch.test.ts +++ b/tests/fetch.test.ts @@ -324,6 +324,50 @@ describe("fetch contracts", () => { expect(result).toMatchObject({ ok: true, data: "ok" }); expect(transport).toHaveBeenCalledTimes(2); }); + it("should validate and clamp Retry-After before enforcing the deadline", async () => { + const exercise = async ( + retryAfter: string, + retryOptions: Parameters[0], + timeout?: number, + ) => { + const transport = vi + .fn<(request: Request) => Promise>() + .mockResolvedValueOnce( + new Response("later", { + status: 429, + headers: { "content-type": "text/plain", "retry-after": retryAfter }, + }), + ) + .mockResolvedValue(new Response("ok", { headers: { "content-type": "text/plain" } })); + const result = await createFetch({ + middleware: [retry(retryOptions)], + fetch: transport, + ...(timeout === undefined ? {} : { timeout }), + })({ url: "https://x.test", response: text(), errors: { 429: text() } }); + return { result, transport }; + }; + + const fallback = vi.fn(() => 1_000); + const malformed = await exercise("not-a-real-date", { attempts: 2, delay: fallback }, 5); + expect(fallback).toHaveBeenCalledWith(2); + expect(malformed.transport).toHaveBeenCalledTimes(1); + + const clamped = await exercise( + "Fri, 31 Dec 9999 23:59:59 GMT", + { + attempts: 2, + maxRetryAfter: 0, + }, + 5, + ); + expect(clamped.result).toMatchObject({ ok: true }); + expect(clamped.transport).toHaveBeenCalledTimes(2); + + const valid = await exercise("0", { attempts: 2, delay: () => 1_000 }); + expect(valid.result).toMatchObject({ ok: true }); + expect(valid.transport).toHaveBeenCalledTimes(2); + expect(() => retry({ maxRetryAfter: Number.NaN })).toThrow(/non-negative finite number/); + }); it.each(["status", "network"] as const)( "should retry replayable PUT bodies after a %s failure", async (failure) => {