diff --git a/src/error.ts b/src/error.ts index f1625f60..f5627fa6 100644 --- a/src/error.ts +++ b/src/error.ts @@ -49,6 +49,12 @@ export function createFetchError( }); } + Object.defineProperty(fetchError, "retries", { + get() { + return ctx.retries?.history; + }, + }); + for (const [key, refKey] of [ ["data", "_data"], ["status", "status"], diff --git a/src/fetch.ts b/src/fetch.ts index 10c91583..ac355a7e 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -7,15 +7,21 @@ import { detectResponseType, resolveFetchOptions, callHooks, + callRetryHooks, + createRetryIntent, + createRetryHistory, + mergeRetryOptions, + sleep, } from "./utils.ts"; import type { CreateFetchOptions, FetchResponse, - ResponseType, FetchContext, $Fetch, FetchRequest, FetchOptions, + RetryEntry, + RetryTrigger, } from "./types.ts"; // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status @@ -36,43 +42,78 @@ const nullBodyResponses = new Set([101, 204, 205, 304]); export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { const { fetch = globalThis.fetch } = globalOptions; - async function onError(context: FetchContext): Promise> { + async function onError( + context: FetchContext, + history: RetryEntry[], + timeoutSignal?: AbortSignal + ): Promise> { // Is Abort // If it is an active abort, it will not retry automatically. // https://developer.mozilla.org/en-US/docs/Web/API/DOMException#error_names const isAbort = (context.error && - context.error.name === "AbortError" && - !context.options.timeout) || + ((context.error.name === "AbortError" && !timeoutSignal?.aborted) || + context.options.signal?.aborted)) || false; - // Retry - if (context.options.retry !== false && !isAbort) { - let retries; - if (typeof context.options.retry === "number") { - retries = context.options.retry; - } else { - retries = 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)) + if (!isAbort) { + let trigger: RetryTrigger = "network"; + if (context.response) { + trigger = "status"; + } else if ( + context.error && + (context.error.name === "TimeoutError" || + (context.error.name === "AbortError" && timeoutSignal?.aborted)) ) { - 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)); + trigger = "timeout"; + } + + // Manual retry claimed by a hook + if (context.pendingRetry) { + return performRetry(context, history, trigger); + } + + // Automatic retry + if (context.options.retry !== false) { + let retries; + if (typeof context.options.retry === "number") { + retries = context.options.retry; + } else { + retries = isPayloadMethod(context.options.method) ? 0 : 1; + } + + // Count the number of automatic retries since the last manual fetch + let autoUsed = 0; + for ( + let i = history.length - 1; + i >= 0 && history[i].cause === "auto"; + i-- + ) { + autoUsed++; + } + + const responseCode = + (context.response && context.response.status) || 500; + if ( + autoUsed < retries && + (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 sleep(retryDelay, context.options.signal ?? undefined); + } + history.push({ + cause: "auto", + trigger, + status: context.response?.status, + }); + return doFetch(context.request, context.options, history); } - // Timeout - return $fetchRaw(context.request, { - ...context.options, - retry: retries - 1, - }); } } @@ -81,25 +122,55 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { // Only available on V8 based runtimes (https://v8.dev/docs/stack-trace-api) if (Error.captureStackTrace) { - Error.captureStackTrace(error, $fetchRaw); + Error.captureStackTrace(error, doFetch); } throw error; } - const $fetchRaw: $Fetch["raw"] = async function $fetchRaw< - T = any, - R extends ResponseType = "json", - >(_request: FetchRequest, _options: FetchOptions = {}) { + async function performRetry( + context: FetchContext, + history: RetryEntry[], + trigger: RetryTrigger + ): Promise> { + const intent = context.pendingRetry!; + history.push({ + cause: intent.cause || "manual", + trigger, + status: context.response?.status, + }); + if (intent.delay && intent.delay > 0) { + await sleep(intent.delay, context.options.signal ?? undefined); + } + return doFetch( + intent.request === undefined ? context.request : intent.request, + intent.options + ? mergeRetryOptions(context.options, intent.options) + : context.options, + history + ); + } + + async function doFetch( + _request: FetchRequest, + _options: FetchOptions, + history: RetryEntry[] + ): Promise> { const context: FetchContext = { request: _request, - options: resolveFetchOptions( + options: resolveFetchOptions( _request, _options, - globalOptions.defaults as unknown as FetchOptions, + globalOptions.defaults, Headers ), response: undefined, error: undefined, + retries: createRetryHistory(history), + pendingRetry: undefined, + retry: (intent) => createRetryIntent(context, intent), + cancelRetry: () => { + context.pendingRetry = undefined; + }, }; // Uppercase method name @@ -166,35 +237,34 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { } } - let abortTimeout: NodeJS.Timeout | undefined; - - if (context.options.timeout) { - context.options.signal = context.options.signal - ? AbortSignal.any([ - AbortSignal.timeout(context.options.timeout), - context.options.signal, - ]) - : AbortSignal.timeout(context.options.timeout); - } + // The timeout applies per attempt: a fresh timeout signal is created for + // every attempt and combined with the caller's signal, which is kept + // untouched on `context.options` so retries start from a clean slate. + const timeoutSignal = context.options.timeout + ? AbortSignal.timeout(context.options.timeout) + : undefined; try { context.response = await fetch( context.request, - context.options as RequestInit + (timeoutSignal + ? { + ...context.options, + signal: context.options.signal + ? AbortSignal.any([timeoutSignal, context.options.signal]) + : timeoutSignal, + } + : context.options) as RequestInit ); } catch (error) { context.error = error as Error; if (context.options.onRequestError) { - await callHooks( + await callRetryHooks( context as FetchContext & { error: Error }, context.options.onRequestError ); } - return await onError(context); - } finally { - if (abortTimeout) { - clearTimeout(abortTimeout); - } + return await onError(context, history, timeoutSignal); } const hasBody = @@ -233,7 +303,7 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { } if (context.options.onResponse) { - await callHooks( + await callRetryHooks( context as FetchContext & { response: FetchResponse }, context.options.onResponse ); @@ -245,16 +315,27 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { context.response.status < 600 ) { if (context.options.onResponseError) { - await callHooks( + await callRetryHooks( context as FetchContext & { response: FetchResponse }, context.options.onResponseError ); } - return await onError(context); + return await onError(context, history, timeoutSignal); + } + + if (context.pendingRetry) { + return performRetry(context, history, "status"); } return context.response; - }; + } + + const $fetchRaw = function $fetchRaw( + _request: FetchRequest, + _options: FetchOptions = {} + ) { + return doFetch(_request, _options, []); + } as $Fetch["raw"]; const $fetch = async function $fetch(request, options) { const r = await $fetchRaw(request, options); diff --git a/src/types.ts b/src/types.ts index 66a84faf..b66e26bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -87,6 +87,56 @@ export type GlobalOptions = Pick< "timeout" | "retry" | "retryDelay" >; +// -------------------------- +// Retry +// -------------------------- + +/** + * Augment via `declare module "ofetch"` to type custom retry causes: + * + * ```ts + * declare module "ofetch" { + * interface FetchTypes { + * retryCause: "auth" | "warmup"; // strict + * retryCause: "auth" | "warmup" | (string & {}); // also allow any string + * } + * } + * ``` + */ +export interface FetchTypes {} + +export type RetryCause = + | "auto" + | "manual" + | (FetchTypes extends { retryCause: infer T extends string } + ? T + : string & {}); + +export type RetryTrigger = "status" | "network" | "timeout"; + +export interface RetryEntry { + cause: RetryCause; + trigger: RetryTrigger; + status?: number; +} + +export interface RetryHistory { + readonly history: readonly RetryEntry[]; + readonly last: RetryEntry | undefined; + count(cause?: RetryCause): number; +} + +export interface RetryIntent { + /** + * `'auto'` is reserved for automatic retries + * @default 'manual' + */ + cause?: Exclude; + request?: FetchRequest; + options?: FetchOptions; + delay?: number; +} + // -------------------------- // Hooks and Context // -------------------------- @@ -96,23 +146,33 @@ export interface FetchContext { options: ResolvedFetchOptions; response?: FetchResponse; error?: Error; + retries: RetryHistory; + pendingRetry?: RetryIntent; + retry(intent?: RetryIntent): RetryIntent; + cancelRetry(): void; } type MaybePromise = T | Promise; type MaybeArray = T | T[]; -export type FetchHook = ( +export type FetchHook = FetchContext> = ( context: C ) => MaybePromise; +export type FetchRetryHook = FetchContext> = ( + context: C +) => MaybePromise>; + export interface FetchHooks { onRequest?: MaybeArray>>; - onRequestError?: MaybeArray & { error: Error }>>; + onRequestError?: MaybeArray< + FetchRetryHook & { error: Error }> + >; onResponse?: MaybeArray< - FetchHook & { response: FetchResponse }> + FetchRetryHook & { response: FetchResponse }> >; onResponseError?: MaybeArray< - FetchHook & { response: FetchResponse }> + FetchRetryHook & { response: FetchResponse }> >; } @@ -151,6 +211,7 @@ export interface IFetchError extends Error { statusText?: string; statusCode?: number; statusMessage?: string; + retries?: readonly RetryEntry[]; } // -------------------------- diff --git a/src/utils.ts b/src/utils.ts index 3bc0dbf5..5936bc61 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,10 +1,15 @@ import type { FetchContext, FetchHook, + FetchRetryHook, FetchOptions, FetchRequest, ResolvedFetchOptions, ResponseType, + RetryCause, + RetryIntent, + RetryHistory, + RetryEntry, } from "./types.ts"; const payloadMethods = new Set( @@ -151,3 +156,111 @@ export async function callHooks( } } } + +export function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + const onDone = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onDone); + resolve(); + }; + const timer = setTimeout(onDone, ms); + signal?.addEventListener("abort", onDone); + }); +} + +const kRetryIntent = /* @__PURE__ */ Symbol.for("ofetch:retryIntent"); + +export function createRetryHistory(history: RetryEntry[]): RetryHistory { + return { + get history() { + return history; + }, + get last() { + return history.at(-1); + }, + count(cause?: RetryCause) { + return cause === undefined + ? history.length + : history.filter((record) => record.cause === cause).length; + }, + }; +} + +export function createRetryIntent< + T = any, + R extends ResponseType = ResponseType, +>( + context: FetchContext, + input: RetryIntent = {} +): RetryIntent { + const body = context.options.body; + if ( + body && + (typeof (body as ReadableStream).pipeTo === "function" || + typeof (body as any).pipe === "function") + ) { + throw new TypeError( + "[ofetch] Cannot retry: request body is a stream and cannot be replayed." + ); + } + return { ...input, [kRetryIntent]: true } as RetryIntent; +} + +export function mergeRetryOptions( + base: FetchOptions | undefined, + patch: FetchOptions +): FetchOptions { + const merged: FetchOptions = { ...base, ...patch }; + if (base?.headers && patch.headers) { + merged.headers = mergeHeaders(patch.headers, base.headers, Headers); + } + return merged; +} + +export async function callRetryHooks< + C extends FetchContext = FetchContext, +>( + context: C, + hooks: FetchRetryHook | FetchRetryHook[] | undefined +): Promise { + if (!hooks) { + return; + } + for (const hook of Array.isArray(hooks) ? hooks : [hooks]) { + const result = await hook(context); + if (result && (result as any)[kRetryIntent]) { + const pending = context.pendingRetry; + if (pending) { + // An intent for a different request is a conflict + if ( + result.request !== undefined && + result.request !== (pending.request ?? context.request) + ) { + console.error( + "[ofetch] Ignoring a retry intent targeting a different request than the pending retry." + ); + continue; + } + if (result.delay !== undefined) { + pending.delay = Math.max(pending.delay || 0, result.delay); + } + if (result.options) { + pending.options = mergeRetryOptions(pending.options, result.options); + } + } else { + context.pendingRetry = { + cause: + !result.cause || result.cause === "auto" ? "manual" : result.cause, + request: result.request, + options: result.options, + delay: result.delay, + } as typeof context.pendingRetry; + } + } + } +} diff --git a/test/retry.test.ts b/test/retry.test.ts new file mode 100644 index 00000000..bc9ea64e --- /dev/null +++ b/test/retry.test.ts @@ -0,0 +1,536 @@ +import { + describe, + beforeAll, + afterAll, + beforeEach, + afterEach, + it, + expect, + vi, +} from "vitest"; +import { Readable } from "node:stream"; +import { H3, HTTPError, serve } from "h3"; +import { $fetch } from "../src/index.ts"; +import { sleep } from "../src/utils.ts"; +import type { RetryEntry, RetryIntent } from "../src/index.ts"; + +describe("retries", () => { + let listener: ReturnType; + const getURL = (url: string) => listener.url! + url.replace(/^\//, ""); + + let authCalls: string[] = []; + const sequences = new Map< + string, + { + calls: number; + statuses: number[]; + delays?: number[]; + onCall?: () => void; + } + >(); + const setSequence = ( + id: string, + statuses: number[], + delays?: number[], + onCall?: () => void + ) => { + sequences.set(id, { calls: 0, statuses, delays, onCall }); + return getURL(`seq?id=${id}`); + }; + + beforeAll(async () => { + const app = new H3() + .all("/auth", (event) => { + const auth = event.req.headers.get("authorization") ?? ""; + authCalls.push(auth); + if (auth === "valid") { + return { + authorization: auth, + extra: event.req.headers.get("x-extra"), + }; + } + return new HTTPError({ status: 401 }); + }) + .all("/seq", async (event) => { + const state = sequences.get(event.url.searchParams.get("id")!)!; + const index = Math.min(state.calls, state.statuses.length - 1); + const status = state.statuses[index]; + state.calls++; + state.onCall?.(); + const delay = state.delays?.[index]; + if (delay) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + if (status >= 400) { + return new HTTPError({ status }); + } + return { calls: state.calls }; + }); + + listener = await serve(app, { port: 0, hostname: "localhost" }).ready(); + }); + + afterAll(() => { + listener.close().catch(console.error); + }); + + beforeEach(() => { + authCalls = []; + }); + + it("replays the request with merged options and re-runs hooks on manual retry", async () => { + let onRequestCalls = 0; + const res = await $fetch<{ authorization: string }>(getURL("auth"), { + onRequest() { + onRequestCalls++; + }, + onResponseError(ctx) { + if (ctx.response.status === 401 && ctx.retries.count("auth") === 0) { + return ctx.retry({ + cause: "auth", + options: { headers: { authorization: "valid" } }, + }); + } + }, + }); + expect(res.authorization).toBe("valid"); + expect(authCalls).toEqual(["", "valid"]); + expect(onRequestCalls).toBe(2); + }); + + it("records retries in the history with a default cause of 'manual'", async () => { + const histories: RetryEntry[][] = []; + await $fetch(getURL("auth"), { + onRequest(ctx) { + histories.push([...ctx.retries.history]); + }, + onResponseError(ctx) { + if (ctx.retries.count() === 0) { + return ctx.retry({ + options: { headers: { authorization: "valid" } }, + }); + } + }, + }); + expect(histories).toEqual([ + [], + [{ cause: "manual", trigger: "status", status: 401 }], + ]); + }); + + it("stops when the guard is exhausted and exposes history on the error", async () => { + const error = await $fetch(getURL("auth"), { + retry: false, + onResponseError(ctx) { + if (ctx.retries.count("auth") === 0) { + return ctx.retry({ cause: "auth" }); + } + }, + }).catch((error_) => error_); + expect(error.status).toBe(401); + expect(authCalls.length).toBe(2); + expect(error.retries).toEqual([ + { cause: "auth", trigger: "status", status: 401 }, + ]); + }); + + it("allows later hooks to merge into the pending retry without changing its cause", async () => { + const histories: RetryEntry[][] = []; + const res = await $fetch<{ authorization: string; extra: string }>( + getURL("auth"), + { + onRequest(ctx) { + histories.push([...ctx.retries.history]); + }, + onResponseError: [ + (ctx) => { + if (ctx.retries.count("auth") === 0) { + return ctx.retry({ + cause: "auth", + options: { headers: { authorization: "valid" } }, + }); + } + }, + (ctx) => { + if (ctx.pendingRetry) { + return ctx.retry({ + cause: "other", + options: { headers: { "x-extra": "merged" } }, + }); + } + }, + ], + } + ); + expect(res.authorization).toBe("valid"); + expect(res.extra).toBe("merged"); + expect(histories[1]).toEqual([ + { cause: "auth", trigger: "status", status: 401 }, + ]); + }); + + it("ignores a merging intent that targets a different request", async () => { + const url = setSequence("merge-conflict", [500, 200]); + const otherUrl = setSequence("merge-conflict-other", [200]); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + let pendingSnapshot: RetryIntent | undefined; + const res = await $fetch<{ calls: number }>(url, { + retry: false, + onResponseError: [ + (ctx) => ctx.retry({ options: { headers: { "x-a": "1" } } }), + (ctx) => + ctx.retry({ + request: otherUrl, + options: { headers: { "x-b": "2" } }, + }), + (ctx) => { + pendingSnapshot = ctx.pendingRetry; + }, + ], + }); + expect(res.calls).toBe(2); + expect(sequences.get("merge-conflict-other")!.calls).toBe(0); + expect(pendingSnapshot?.request).toBeUndefined(); + expect(pendingSnapshot?.options?.headers).toEqual({ "x-a": "1" }); + expect(consoleError).toHaveBeenCalledOnce(); + consoleError.mockRestore(); + }); + + it("keeps the longest delay when merging intents", async () => { + const url = setSequence("merge-delay", [500, 200]); + const delays: (number | undefined)[] = []; + const res = await $fetch<{ calls: number }>(url, { + retry: false, + onResponseError: [ + (ctx) => ctx.retry({ delay: 20 }), + (ctx) => ctx.retry({ delay: 5 }), + (ctx) => { + delays.push(ctx.pendingRetry?.delay); + }, + (ctx) => ctx.retry({ delay: 40 }), + (ctx) => { + delays.push(ctx.pendingRetry?.delay); + }, + ], + }); + expect(res.calls).toBe(2); + expect(delays).toEqual([20, 40]); + }); + + it("clears a pending retry when cancelRetry is called", async () => { + const error = await $fetch(getURL("auth"), { + onResponseError: [ + (ctx) => + ctx.retry({ + cause: "auth", + options: { headers: { authorization: "valid" } }, + }), + (ctx) => { + ctx.cancelRetry(); + }, + ], + }).catch((error_) => error_); + expect(error.status).toBe(401); + expect(authCalls.length).toBe(1); + expect(error.retries).toEqual([]); + }); + + it("allows a later hook to claim a new retry after a cancel", async () => { + const res = await $fetch<{ authorization: string }>(getURL("auth"), { + onResponseError: [ + (ctx) => { + if (ctx.retries.count() === 0) { + return ctx.retry({ cause: "first" }); + } + }, + (ctx) => { + ctx.cancelRetry(); + }, + (ctx) => { + if (ctx.retries.count() === 0) { + return ctx.retry({ + cause: "second", + options: { headers: { authorization: "valid" } }, + }); + } + }, + ], + }); + expect(res.authorization).toBe("valid"); + expect(authCalls).toEqual(["", "valid"]); + }); + + it("auto-retries again after a manual retry even when the budget was already spent", async () => { + const url = setSequence("budget", [500, 401, 500, 200]); + const codes: number[] = []; + const res = await $fetch<{ calls: number }>(url, { + retry: 1, + onResponse(ctx) { + codes.push(ctx.response.status); + }, + onResponseError(ctx) { + if (ctx.response.status === 401 && ctx.retries.count("auth") === 0) { + return ctx.retry({ cause: "auth" }); + } + }, + }); + expect(res.calls).toBe(4); + expect(codes).toMatchInlineSnapshot(` + [ + 500, + 401, + 500, + 200, + ] + `); + }); + + it("keeps the `retry` option stable across attempts", async () => { + const url = setSequence("stable", [500, 500, 500]); + const seen: (number | false | undefined)[] = []; + const error = await $fetch(url, { + retry: 2, + onRequest(ctx) { + seen.push(ctx.options.retry); + }, + }).catch((error_) => error_); + expect(seen).toEqual([2, 2, 2]); + expect(error.status).toBe(500); + expect(error.retries).toEqual([ + { cause: "auto", trigger: "status", status: 500 }, + { cause: "auto", trigger: "status", status: 500 }, + ]); + }); + + it("allows retrying from onRequestError with a new request", async () => { + let history: readonly RetryEntry[] = []; + const res = await $fetch<{ calls: number }>("http://localhost:1/nope", { + retry: false, + onRequestError(ctx) { + if (ctx.retries.count() === 0) { + return ctx.retry({ request: setSequence("network", [200]) }); + } + }, + onResponse(ctx) { + history = [...ctx.retries.history]; + }, + }); + expect(res.calls).toBe(1); + expect(history).toEqual([ + { cause: "manual", trigger: "network", status: undefined }, + ]); + }); + + it("allows retrying from onResponse on a successful response (polling)", async () => { + const url = setSequence("poll", [200]); + const res = await $fetch<{ calls: number }>(url, { + onResponse(ctx) { + if (ctx.response._data?.calls < 3 && ctx.retries.count("poll") < 5) { + return ctx.retry({ cause: "poll" }); + } + }, + }); + expect(res.calls).toBe(3); + }); + + it("coerces a manual cause of 'auto' to 'manual'", async () => { + const error = await $fetch(getURL("auth"), { + retry: false, + onResponseError(ctx) { + if (ctx.retries.count() === 0) { + return ctx.retry({ cause: "auto" }); + } + }, + }).catch((error_) => error_); + expect(error.status).toBe(401); + expect(error.retries).toEqual([ + { cause: "manual", trigger: "status", status: 401 }, + ]); + }); + + it("aborts immediately during an automatic retry delay", async () => { + const url = setSequence("abort-auto-delay", [500, 200]); + const controller = new AbortController(); + const start = Date.now(); + const promise = $fetch(url, { + retry: 1, + retryDelay: 5000, + signal: controller.signal, + onResponseError() { + setTimeout(() => controller.abort(), 0); + }, + }); + await expect(promise).rejects.toThrow(/abort/i); + expect(Date.now() - start).toBeLessThan(1000); + }); + + it("aborts immediately during a manual retry delay", async () => { + const url = setSequence("abort-manual-delay", [500, 200]); + const controller = new AbortController(); + const start = Date.now(); + const promise = $fetch(url, { + retry: false, + signal: controller.signal, + onResponseError(ctx) { + if (ctx.retries.count() === 0) { + setTimeout(() => controller.abort(), 0); + return ctx.retry({ delay: 5000 }); + } + }, + }); + await expect(promise).rejects.toThrow(/abort/i); + expect(Date.now() - start).toBeLessThan(1000); + }); + + it("doesn't run a pending manual retry after an abort", async () => { + const controller = new AbortController(); + const error = await $fetch(getURL("auth"), { + signal: controller.signal, + onResponseError(ctx) { + controller.abort(); + return ctx.retry({ + cause: "auth", + options: { headers: { authorization: "valid" } }, + }); + }, + }).catch((error_) => error_); + expect(error.message).toMatch(/abort/i); + expect(authCalls).toEqual([""]); + expect(error.retries).toEqual([ + { cause: "auth", trigger: "status", status: 401 }, + ]); + }); + + it("gives each retry attempt a fresh timeout", async () => { + const url = setSequence("timeout-fresh", [200, 200], [100, 0]); + const histories: RetryEntry[][] = []; + const res = await $fetch<{ calls: number }>(url, { + retry: 1, + timeout: 50, + onResponse(ctx) { + histories.push([...ctx.retries.history]); + }, + }); + expect(res.calls).toBe(2); + expect(histories.at(-1)).toEqual([ + { cause: "auto", trigger: "timeout", status: undefined }, + ]); + }); + + it("applies the timeout to each attempt, not to the whole retry chain", async () => { + const url = setSequence( + "timeout-per-attempt", + [500, 500, 200], + [60, 60, 60] + ); + const res = await $fetch<{ calls: number }>(url, { + retry: 2, + timeout: 150, + }); + expect(res.calls).toBe(3); + }); + + it("re-attempts the request after a timeout until the budget is exhausted", async () => { + const url = setSequence("timeout-exhausted", [200, 200], [100, 100]); + const error = await $fetch(url, { retry: 1, timeout: 10 }).catch( + (error_) => error_ + ); + expect(error.message).toMatch(/timeout/i); + expect(sequences.get("timeout-exhausted")!.calls).toBe(2); + expect(error.retries).toEqual([ + { cause: "auto", trigger: "timeout", status: undefined }, + ]); + }); + + it("does not auto-retry when the caller aborts while a timeout is set", async () => { + const controller = new AbortController(); + const url = setSequence("timeout-user-abort", [200], [100], () => + controller.abort() + ); + const error = await $fetch(url, { + retry: 1, + timeout: 5000, + signal: controller.signal, + }).catch((error_) => error_); + expect(error.message).toMatch(/abort/i); + expect(error.retries).toEqual([]); + expect(sequences.get("timeout-user-abort")!.calls).toBe(1); + }); + + it("does not retry when the caller aborts with a custom reason", async () => { + const controller = new AbortController(); + const url = setSequence("custom-abort-reason", [200], [100], () => + controller.abort(new Error("user cancelled")) + ); + const error = await $fetch(url, { + retry: 1, + timeout: 5000, + signal: controller.signal, + }).catch((error_) => error_); + expect(error.cause).toBeInstanceOf(Error); + expect((error.cause as Error).message).toBe("user cancelled"); + expect(error.retries).toEqual([]); + expect(sequences.get("custom-abort-reason")!.calls).toBe(1); + }); + + it("throws when ctx.retry() is called with a stream body", async () => { + const url = setSequence("stream", [500]); + await expect( + $fetch(url, { + method: "POST", + body: Readable.from(["hello"]), + retry: false, + onResponseError(ctx) { + return ctx.retry(); + }, + }) + ).rejects.toThrow("cannot be replayed"); + }); +}); + +describe("sleep", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("resolves after the given duration", async () => { + let resolved = false; + sleep(1000).then(() => { + resolved = true; + }); + await vi.advanceTimersByTimeAsync(999); + expect(resolved).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(resolved).toBe(true); + }); + + it("resolves immediately when the signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + let resolved = false; + sleep(60_000, controller.signal).then(() => { + resolved = true; + }); + await vi.advanceTimersByTimeAsync(0); + expect(resolved).toBe(true); + }); + + it("resolves early when the signal aborts mid-sleep", async () => { + const controller = new AbortController(); + let resolved = false; + sleep(60_000, controller.signal).then(() => { + resolved = true; + }); + await vi.advanceTimersByTimeAsync(1000); + expect(resolved).toBe(false); + controller.abort(); + await vi.advanceTimersByTimeAsync(0); + expect(resolved).toBe(true); + }); +});