diff --git a/src/fetch.ts b/src/fetch.ts index 10c91583..233c1d87 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,62 @@ 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) { + // 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); + } + + if (shouldRetry) { + 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 +119,34 @@ 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 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, - _options, - globalOptions.defaults as unknown as FetchOptions, - Headers - ), + options: resolvedOptions, response: undefined, error: undefined, + retry: _retryState ?? { attempt: 0, limit: retryLimit }, }; // Uppercase method name diff --git a/src/types.ts b/src/types.ts index 66a84faf..0fc7c30d 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. + * When provided, replaces the default status code check entirely. + * When absent, falls back to matching against `retryStatusCodes`. + */ + 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..d368e631 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,147 @@ describe("ofetch", () => { timeout: 10_000, }); }); + + describe("retry", () => { + it("exposes retry state in context", async () => { + const states: Array<{ attempt: number; limit: number }> = []; + await $fetch(getURL("408"), { + retry: 2, + retryDelay: 1, + onResponseError(ctx) { + states.push({ + attempt: ctx.retry?.attempt ?? -1, + limit: ctx.retry?.limit ?? -1, + }); + }, + }).catch(() => {}); + // 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 () => { + 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 replaces default status code check", async () => { + // retryCondition: () => false should prevent retry even for 408 + await $fetch(getURL("408"), { + retry: 1, + retryDelay: 1, + retryCondition: () => false, + }).catch((error: any) => { + expect(error.status).toBe(408); + }); + // 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); + }); + + 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; + }); +});