diff --git a/README.md b/README.md index c7401052..bb880cc7 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,16 @@ await ofetch("http://google.com/404", { }); ``` +You can also pass a callback as a `retry` option. It takes a fetch context object and the count of retries and returns a boolean. + +```ts +await $fetch("/api", { + retry: (ctx, count) => { + return count <= 3 && ctx.error?.code === "007"; + }, +}); +``` + ## ✔️ Timeout You can specify `timeout` in milliseconds to automatically abort a request after a timeout (default is disabled). diff --git a/src/fetch.ts b/src/fetch.ts index c0021c08..f18a5e2c 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -37,7 +37,9 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { AbortController = globalThis.AbortController, } = globalOptions; - async function onError(context: FetchContext): Promise> { + async function getRetryResponse( + context: FetchContext + ): Promise | void> { // 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 @@ -56,24 +58,35 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { } 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 = context.options.retryDelay || 0; + const hasStatusCode = Array.isArray(context.options.retryStatusCodes) + ? context.options.retryStatusCodes.includes(responseCode) + : retryStatusCodes.has(responseCode); + // @ts-expect-error value for internal use + const { retry, retryDelay = 0, _retryCount = 1 } = context.options; + const shouldRetry = + typeof retry === "function" ? await retry(context, _retryCount) : false; + + if ((retries > 0 && hasStatusCode) || shouldRetry) { if (retryDelay > 0) { await new Promise((resolve) => setTimeout(resolve, retryDelay)); } // Timeout return $fetchRaw(context.request, { ...context.options, - retry: retries - 1, - timeout: context.options.timeout, + retry: typeof retry === "function" ? retry : retries - 1, + // @ts-expect-error value for internal use + _retryCount: _retryCount + 1, }); } } + } + + async function onError(context: FetchContext): Promise> { + const retryResponse = await getRetryResponse(context); + + if (retryResponse) { + return retryResponse; + } // Throw normalized error const error = createFetchError(context); @@ -213,7 +226,9 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { return await onError(context); } - return context.response; + const retryResponse = await getRetryResponse(context); + + return retryResponse || context.response; }; const $fetch = async function $fetch(request, options) { diff --git a/src/types.ts b/src/types.ts index d4f2e11c..7697a6f7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -51,7 +51,10 @@ export interface FetchOptions /** timeout in milliseconds */ timeout?: number; - retry?: number | false; + retry?: + | number + | false + | ((context: FetchContext, count: number) => Promise | boolean); /** Delay between retries in milliseconds. */ retryDelay?: number; /** Default is [408, 409, 425, 429, 500, 502, 503, 504] */ diff --git a/test/index.test.ts b/test/index.test.ts index de25338f..86c13005 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -9,7 +9,7 @@ import { readRawBody, toNodeListener, } from "h3"; -import { describe, beforeAll, afterAll, it, expect } from "vitest"; +import { describe, beforeAll, afterAll, it, expect, vi } from "vitest"; import { Headers, FormData, Blob } from "node-fetch-native"; import { nodeMajorVersion } from "std-env"; import { $fetch } from "../src/node"; @@ -287,6 +287,13 @@ describe("ofetch", () => { expect(race).to.equal("fast"); }); + it("retry callback", async () => { + const retry = vi.fn().mockImplementation((_, count) => count <= 3); + await $fetch(getURL("ok"), { retry }); + + expect(retry).toHaveBeenCalledTimes(4); + }); + it("abort with retry", () => { const controller = new AbortController(); async function abortHandle() {