Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 74 additions & 27 deletions src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
FetchResponse,
ResponseType,
FetchContext,
FetchRetryState,
$Fetch,
FetchRequest,
FetchOptions,
Expand Down Expand Up @@ -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,
});
}
}

Expand All @@ -90,16 +119,34 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
T = any,
R extends ResponseType = "json",
>(_request: FetchRequest, _options: FetchOptions<R> = {}) {
// Extract internal retry state passed from onError
const { _retryState, ...userOptions } = _options as FetchOptions<R> & {
_retryState?: FetchRetryState;
};

const resolvedOptions = resolveFetchOptions<R, T>(
_request,
userOptions as FetchOptions<R>,
globalOptions.defaults as unknown as FetchOptions<R, T>,
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<R, T>(
_request,
_options,
globalOptions.defaults as unknown as FetchOptions<R, T>,
Headers
),
options: resolvedOptions,
response: undefined,
error: undefined,
retry: _retryState ?? { attempt: 0, limit: retryLimit },
};

// Uppercase method name
Expand Down
20 changes: 20 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ export interface FetchOptions<R extends ResponseType = ResponseType, T = any>

/** 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<T, R>) => boolean | Promise<boolean>;
}

export interface ResolvedFetchOptions<
Expand All @@ -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<T = any, R extends ResponseType = ResponseType> {
request: FetchRequest;
options: ResolvedFetchOptions<R>;
response?: FetchResponse<T>;
error?: Error;
/** Retry state for the current request */
retry?: FetchRetryState;
}

type MaybePromise<T> = T | Promise<T>;
Expand All @@ -114,6 +130,10 @@ export interface FetchHooks<T = any, R extends ResponseType = ResponseType> {
onResponseError?: MaybeArray<
FetchHook<FetchContext<T, R> & { response: FetchResponse<T> }>
>;
/** Called before a retry attempt. Can be used to modify options (e.g., refresh tokens). */
onRetry?: MaybeArray<
FetchHook<FetchContext<T, R> & { retry: FetchRetryState }>
>;
}

// --------------------------
Expand Down
155 changes: 155 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand All @@ -73,6 +83,8 @@ describe("ofetch", () => {

beforeEach(() => {
fetch.mockClear();
retryCount = 0;
retryMax = 2;
});

it("ok", async () => {
Expand Down Expand Up @@ -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 }]);
});
});
});
Loading