From b551fb25797c8e43701da584b6d55efa744ec6e5 Mon Sep 17 00:00:00 2001 From: productdevbook Date: Sun, 29 Mar 2026 11:33:27 +0300 Subject: [PATCH 1/2] feat: enhance retry system with attempt tracking, retryCondition, and onRetry hook - Add `FetchRetryState` to `FetchContext` with `attempt` and `limit` fields - Add `retryCondition` option for custom retry logic beyond status codes - Add `onRetry` hook called before each retry attempt (e.g., token refresh) - Fix retry not checking custom `retryStatusCodes` for client errors (#495) - `retryDelay` callback now receives full context with retry state (#536) Resolves #536, #503, #358, #495 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fetch.ts | 80 +++++++++++++++++++-------- src/types.ts | 20 +++++++ test/index.test.ts | 128 +++++++++++++++++++++++++++++++++++++++++++ test/types.test-d.ts | 76 +++++++++++++++++++++++++ 4 files changed, 282 insertions(+), 22 deletions(-) create mode 100644 test/types.test-d.ts diff --git a/src/fetch.ts b/src/fetch.ts index 10c91583..50e6e6f4 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -13,6 +13,7 @@ import type { FetchResponse, ResponseType, FetchContext, + FetchRetryState, $Fetch, FetchRequest, FetchOptions, @@ -45,34 +46,63 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { context.error.name === "AbortError" && !context.options.timeout) || false; + // Retry if (context.options.retry !== false && !isAbort) { - let retries; + let retryLimit: number; if (typeof context.options.retry === "number") { - retries = context.options.retry; + retryLimit = context.options.retry; } else { - retries = isPayloadMethod(context.options.method) ? 0 : 1; + retryLimit = isPayloadMethod(context.options.method) ? 0 : 1; } - const responseCode = (context.response && context.response.status) || 500; - if ( - retries > 0 && - (Array.isArray(context.options.retryStatusCodes) - ? context.options.retryStatusCodes.includes(responseCode) - : retryStatusCodes.has(responseCode)) - ) { - const retryDelay = - typeof context.options.retryDelay === "function" - ? context.options.retryDelay(context) - : context.options.retryDelay || 0; - if (retryDelay > 0) { - await new Promise((resolve) => setTimeout(resolve, retryDelay)); + const currentAttempt = context.retry?.attempt ?? 0; + + if (retryLimit > 0 && currentAttempt < retryLimit) { + const responseCode = context.response?.status; + + // Check retry conditions: status code match OR custom retryCondition + let statusCodeMatch = false; + if (responseCode) { + statusCodeMatch = Array.isArray(context.options.retryStatusCodes) + ? context.options.retryStatusCodes.includes(responseCode) + : retryStatusCodes.has(responseCode); + } + + const conditionMatch = context.options.retryCondition + ? await context.options.retryCondition(context) + : false; + + if (statusCodeMatch || conditionMatch) { + const retryState: FetchRetryState = { + attempt: currentAttempt + 1, + limit: retryLimit, + }; + + // Attach retry state to context for hooks + context.retry = retryState; + + // Call onRetry hooks before retrying + if (context.options.onRetry) { + await callHooks( + context as FetchContext & { retry: FetchRetryState }, + context.options.onRetry + ); + } + + const retryDelay = + typeof context.options.retryDelay === "function" + ? context.options.retryDelay(context) + : context.options.retryDelay || 0; + if (retryDelay > 0) { + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + } + return $fetchRaw(context.request, { + ...context.options, + retry: retryLimit, + _retryState: retryState, + } as any); } - // Timeout - return $fetchRaw(context.request, { - ...context.options, - retry: retries - 1, - }); } } @@ -90,16 +120,22 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { T = any, R extends ResponseType = "json", >(_request: FetchRequest, _options: FetchOptions = {}) { + // Extract internal retry state passed from onError + const { _retryState, ...userOptions } = _options as FetchOptions & { + _retryState?: FetchRetryState; + }; + const context: FetchContext = { request: _request, options: resolveFetchOptions( _request, - _options, + userOptions as FetchOptions, globalOptions.defaults as unknown as FetchOptions, Headers ), response: undefined, error: undefined, + retry: _retryState ?? { attempt: 0, limit: 0 }, }; // Uppercase method name diff --git a/src/types.ts b/src/types.ts index 66a84faf..7102a8d8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,6 +68,13 @@ export interface FetchOptions /** Default is [408, 409, 425, 429, 500, 502, 503, 504] */ retryStatusCodes?: number[]; + + /** + * Custom condition to determine whether to retry a request. + * Called after status code check. Retries if either `retryStatusCodes` match + * OR `retryCondition` returns `true`. + */ + retryCondition?: (context: FetchContext) => boolean | Promise; } export interface ResolvedFetchOptions< @@ -91,11 +98,20 @@ export type GlobalOptions = Pick< // Hooks and Context // -------------------------- +export interface FetchRetryState { + /** Current retry attempt number (starts at 0 for initial request) */ + attempt: number; + /** Configured maximum number of retries */ + limit: number; +} + export interface FetchContext { request: FetchRequest; options: ResolvedFetchOptions; response?: FetchResponse; error?: Error; + /** Retry state for the current request */ + retry?: FetchRetryState; } type MaybePromise = T | Promise; @@ -114,6 +130,10 @@ export interface FetchHooks { onResponseError?: MaybeArray< FetchHook & { response: FetchResponse }> >; + /** Called before a retry attempt. Can be used to modify options (e.g., refresh tokens). */ + onRetry?: MaybeArray< + FetchHook & { retry: FetchRetryState }> + >; } // -------------------------- diff --git a/test/index.test.ts b/test/index.test.ts index 5ac20b07..3b1c635e 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -16,6 +16,9 @@ describe("ofetch", () => { const getURL = (url: string = "/") => listener.url! + (url.replace(/^\//, "") || ""); + let retryCount = 0; + let retryMax = 2; + const fetch = vi.spyOn(globalThis, "fetch"); beforeAll(async () => { @@ -62,6 +65,13 @@ describe("ofetch", () => { resolve(new HTTPError({ status: 408 })); }, 1000 * 5); }); + }) + .all("/retry-count", () => { + retryCount++; + if (retryCount <= retryMax) { + return new HTTPError({ status: 500 }); + } + return { count: retryCount }; }); listener = await serve(app, { port: 0, hostname: "localhost" }).ready(); @@ -73,6 +83,8 @@ describe("ofetch", () => { beforeEach(() => { fetch.mockClear(); + retryCount = 0; + retryMax = 2; }); it("ok", async () => { @@ -524,4 +536,120 @@ describe("ofetch", () => { timeout: 10_000, }); }); + + describe("retry", () => { + it("exposes retry state in context", async () => { + const attempts: number[] = []; + await $fetch(getURL("408"), { + retry: 2, + retryDelay: 1, + onResponseError(ctx) { + attempts.push(ctx.retry?.attempt ?? -1); + }, + }).catch(() => {}); + // Initial request (attempt 0), retry 1 (attempt 1), retry 2 (attempt 2) + expect(attempts).toEqual([0, 1, 2]); + }); + + it("exposes retry state in retryDelay callback", async () => { + const attempts: number[] = []; + await $fetch(getURL("408"), { + retry: 3, + retryDelay(ctx) { + attempts.push(ctx.retry?.attempt ?? -1); + return 1; + }, + }).catch(() => {}); + // retryDelay is called before each retry with the upcoming attempt number + expect(attempts).toEqual([1, 2, 3]); + }); + + it("supports retryCondition callback", async () => { + retryMax = 1; + const result = await $fetch(getURL("retry-count"), { + retry: 3, + retryDelay: 1, + retryCondition(ctx) { + return ctx.response?.status === 500; + }, + }); + expect(result).toEqual({ count: 2 }); + }); + + it("retryCondition works alongside retryStatusCodes", async () => { + // Should retry on 408 (via retryStatusCodes) even without retryCondition + await $fetch(getURL("408"), { + retry: 1, + retryDelay: 1, + retryCondition: () => false, + }).catch((error: any) => { + expect(error.status).toBe(408); + }); + // fetch called for initial + 1 retry = 2 times + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("calls onRetry hook before each retry", async () => { + const onRetryCalls: Array<{ attempt: number; limit: number }> = []; + await $fetch(getURL("408"), { + retry: 2, + retryDelay: 1, + onRetry(ctx) { + onRetryCalls.push({ ...ctx.retry }); + }, + }).catch(() => {}); + expect(onRetryCalls).toEqual([ + { attempt: 1, limit: 2 }, + { attempt: 2, limit: 2 }, + ]); + }); + + it("onRetry can modify request options (e.g., refresh token)", async () => { + const headers: string[] = []; + const customFetch = $fetch.create({ + headers: { Authorization: "Bearer expired" }, + }); + await customFetch(getURL("408"), { + retry: 1, + retryDelay: 1, + onRetry(ctx) { + ctx.options.headers.set("Authorization", "Bearer refreshed"); + }, + onRequest(ctx) { + headers.push(ctx.options.headers.get("Authorization") ?? ""); + }, + }).catch(() => {}); + expect(headers[0]).toBe("Bearer expired"); + expect(headers[1]).toBe("Bearer refreshed"); + }); + + it("does not retry on abort even with retryCondition", async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + $fetch(getURL("ok"), { + signal: controller.signal, + retry: 3, + retryCondition: () => true, + }) + ).rejects.toThrow(/aborted/); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("retryCondition receives error for network failures", async () => { + const conditions: Array<{ hasError: boolean; hasResponse: boolean }> = []; + await $fetch("http://localhost:1", { + retry: 1, + retryDelay: 1, + retryCondition(ctx) { + conditions.push({ + hasError: !!ctx.error, + hasResponse: !!ctx.response, + }); + return true; + }, + }).catch(() => {}); + expect(conditions).toEqual([{ hasError: true, hasResponse: false }]); + }); + }); }); diff --git a/test/types.test-d.ts b/test/types.test-d.ts new file mode 100644 index 00000000..7c6b3df9 --- /dev/null +++ b/test/types.test-d.ts @@ -0,0 +1,76 @@ +import { describe, expectTypeOf, it } from "vitest"; +import type { + FetchContext, + FetchRetryState, + FetchOptions, + FetchHooks, +} from "../src/types.ts"; + +describe("retry types", () => { + it("FetchRetryState has correct shape", () => { + expectTypeOf().toHaveProperty("attempt"); + expectTypeOf().toHaveProperty("limit"); + expectTypeOf().toBeNumber(); + expectTypeOf().toBeNumber(); + }); + + it("FetchContext includes optional retry state", () => { + expectTypeOf().toHaveProperty("retry"); + expectTypeOf().toEqualTypeOf< + FetchRetryState | undefined + >(); + }); + + it("retryCondition accepts FetchContext and returns boolean or Promise", () => { + const opts: FetchOptions = { + retryCondition: (ctx) => { + expectTypeOf(ctx).toMatchTypeOf(); + return true; + }, + }; + expectTypeOf(opts.retryCondition).toEqualTypeOf< + | (( + context: FetchContext< + any, + "json" | "text" | "blob" | "arrayBuffer" | "stream" + > + ) => boolean | Promise) + | undefined + >(); + }); + + it("retryDelay callback receives context with retry state", () => { + const opts: FetchOptions = { + retryDelay: (ctx) => { + expectTypeOf(ctx.retry).toEqualTypeOf(); + return 1000; + }, + }; + void opts; + }); + + it("onRetry hook receives context with required retry state", () => { + const hooks: FetchHooks = { + onRetry: (ctx) => { + expectTypeOf(ctx.retry).toEqualTypeOf(); + expectTypeOf(ctx.retry.attempt).toBeNumber(); + expectTypeOf(ctx.retry.limit).toBeNumber(); + }, + }; + void hooks; + }); + + it("onRetry can be an array of hooks", () => { + const hooks: FetchHooks = { + onRetry: [ + (ctx) => { + expectTypeOf(ctx.retry).toEqualTypeOf(); + }, + async (ctx) => { + expectTypeOf(ctx.retry).toEqualTypeOf(); + }, + ], + }; + void hooks; + }); +}); From 910b813722e238714d64fe1aa598cde54f3ef1b1 Mon Sep 17 00:00:00 2001 From: productdevbook Date: Mon, 30 Mar 2026 10:25:01 +0300 Subject: [PATCH 2/2] fix: restore network error retry, fix retryCondition semantics, correct retry.limit init - Restore fallback to 500 for network errors (no response) to maintain backward-compatible retry behavior - Change retryCondition to replacement semantics: when provided, it decides whether to retry (replaces status code check entirely) - Initialize retry.limit correctly on first request (was 0, now computed) - Add tests for network error retry, retryCondition suppression, limit tracking Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fetch.ts | 47 ++++++++++++++++++++++++++++------------------ src/types.ts | 4 ++-- test/index.test.ts | 41 +++++++++++++++++++++++++++++++++------- 3 files changed, 65 insertions(+), 27 deletions(-) diff --git a/src/fetch.ts b/src/fetch.ts index 50e6e6f4..233c1d87 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -59,21 +59,20 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { const currentAttempt = context.retry?.attempt ?? 0; if (retryLimit > 0 && currentAttempt < retryLimit) { - const responseCode = context.response?.status; - - // Check retry conditions: status code match OR custom retryCondition - let statusCodeMatch = false; - if (responseCode) { - statusCodeMatch = Array.isArray(context.options.retryStatusCodes) + // Fallback to 500 for network errors (no response) + const responseCode = context.response?.status ?? 500; + + // If retryCondition is provided, it decides. Otherwise fall back to status code check. + let shouldRetry: boolean; + if (context.options.retryCondition) { + shouldRetry = await context.options.retryCondition(context); + } else { + shouldRetry = Array.isArray(context.options.retryStatusCodes) ? context.options.retryStatusCodes.includes(responseCode) : retryStatusCodes.has(responseCode); } - const conditionMatch = context.options.retryCondition - ? await context.options.retryCondition(context) - : false; - - if (statusCodeMatch || conditionMatch) { + if (shouldRetry) { const retryState: FetchRetryState = { attempt: currentAttempt + 1, limit: retryLimit, @@ -125,17 +124,29 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { _retryState?: FetchRetryState; }; + const resolvedOptions = resolveFetchOptions( + _request, + userOptions as FetchOptions, + globalOptions.defaults as unknown as FetchOptions, + Headers + ); + + // Compute retry limit for initial context + let retryLimit = 0; + if (resolvedOptions.retry !== false) { + if (typeof resolvedOptions.retry === "number") { + retryLimit = resolvedOptions.retry; + } else { + retryLimit = isPayloadMethod(resolvedOptions.method) ? 0 : 1; + } + } + const context: FetchContext = { request: _request, - options: resolveFetchOptions( - _request, - userOptions as FetchOptions, - globalOptions.defaults as unknown as FetchOptions, - Headers - ), + options: resolvedOptions, response: undefined, error: undefined, - retry: _retryState ?? { attempt: 0, limit: 0 }, + retry: _retryState ?? { attempt: 0, limit: retryLimit }, }; // Uppercase method name diff --git a/src/types.ts b/src/types.ts index 7102a8d8..0fc7c30d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -71,8 +71,8 @@ export interface FetchOptions /** * Custom condition to determine whether to retry a request. - * Called after status code check. Retries if either `retryStatusCodes` match - * OR `retryCondition` returns `true`. + * When provided, replaces the default status code check entirely. + * When absent, falls back to matching against `retryStatusCodes`. */ retryCondition?: (context: FetchContext) => boolean | Promise; } diff --git a/test/index.test.ts b/test/index.test.ts index 3b1c635e..d368e631 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -539,16 +539,23 @@ describe("ofetch", () => { describe("retry", () => { it("exposes retry state in context", async () => { - const attempts: number[] = []; + const states: Array<{ attempt: number; limit: number }> = []; await $fetch(getURL("408"), { retry: 2, retryDelay: 1, onResponseError(ctx) { - attempts.push(ctx.retry?.attempt ?? -1); + states.push({ + attempt: ctx.retry?.attempt ?? -1, + limit: ctx.retry?.limit ?? -1, + }); }, }).catch(() => {}); - // Initial request (attempt 0), retry 1 (attempt 1), retry 2 (attempt 2) - expect(attempts).toEqual([0, 1, 2]); + // limit is always correct, attempt increments + expect(states).toEqual([ + { attempt: 0, limit: 2 }, + { attempt: 1, limit: 2 }, + { attempt: 2, limit: 2 }, + ]); }); it("exposes retry state in retryDelay callback", async () => { @@ -576,8 +583,8 @@ describe("ofetch", () => { expect(result).toEqual({ count: 2 }); }); - it("retryCondition works alongside retryStatusCodes", async () => { - // Should retry on 408 (via retryStatusCodes) even without retryCondition + it("retryCondition replaces default status code check", async () => { + // retryCondition: () => false should prevent retry even for 408 await $fetch(getURL("408"), { retry: 1, retryDelay: 1, @@ -585,7 +592,27 @@ describe("ofetch", () => { }).catch((error: any) => { expect(error.status).toBe(408); }); - // fetch called for initial + 1 retry = 2 times + // No retry — retryCondition returned false + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("falls back to retryStatusCodes when no retryCondition", async () => { + await $fetch(getURL("408"), { + retry: 1, + retryDelay: 1, + }).catch((error: any) => { + expect(error.status).toBe(408); + }); + // 408 is in default retryStatusCodes, so 1 retry happens + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("retries network errors by default (no response)", async () => { + await $fetch("http://localhost:1", { + retry: 1, + retryDelay: 1, + }).catch(() => {}); + // Network error falls back to 500 which is in retryStatusCodes expect(fetch).toHaveBeenCalledTimes(2); });