From 953784c00026cbe2b37b14c4957926724a3b6451 Mon Sep 17 00:00:00 2001 From: Muhammad Faizan Uddin Date: Mon, 17 Nov 2025 15:11:42 -0700 Subject: [PATCH] feat: add retryIf option (#503) --- src/fetch.ts | 19 ++++++---- src/types.ts | 6 ++++ test/index.test.ts | 87 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/src/fetch.ts b/src/fetch.ts index 10c91583..341a8609 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -55,12 +55,19 @@ 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 isRetryableStatus = Array.isArray(context.options.retryStatusCodes) + ? context.options.retryStatusCodes.includes(responseCode) + : retryStatusCodes.has(responseCode); + + const isConditionalRetry = + typeof context.options.retryIf === "function" + ? await context.options.retryIf(context) + : false; + + const shouldRetry = isRetryableStatus || isConditionalRetry; + + if (retries > 0 && shouldRetry) { const retryDelay = typeof context.options.retryDelay === "function" ? context.options.retryDelay(context) diff --git a/src/types.ts b/src/types.ts index 66a84faf..a2cdf0c1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,6 +68,12 @@ export interface FetchOptions /** Default is [408, 409, 425, 429, 500, 502, 503, 504] */ retryStatusCodes?: number[]; + + /** + * Condition to retry the request. + * @default false + */ + retryIf?: (context: FetchContext) => boolean | Promise; } export interface ResolvedFetchOptions< diff --git a/test/index.test.ts b/test/index.test.ts index 5ac20b07..2cef7e27 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -344,6 +344,93 @@ describe("ofetch", () => { }); }); + it("retries when retryIf (sync) returns true", async () => { + let called = 0; + await $fetch(getURL("408"), { + retry: 2, + retryIf: () => { + called++; + return true; + }, + }).catch(() => "failed"); + expect(called).to.equal(3); + }); + + it("retries when retryIf (async) returns true", async () => { + let called = 0; + await $fetch(getURL("408"), { + retry: 2, + retryIf: async () => { + called++; + return true; + }, + }).catch(() => "failed"); + expect(called).to.equal(3); + }); + + it("does not retry when retryIf returns false and status does not match", async () => { + let called = 0; + await $fetch(getURL("404"), { + retry: 2, + retryIf: () => { + called++; + return false; + }, + }).catch(() => "failed"); + expect(called).to.equal(1); + }); + + it("retries on matching status even if retryIf returns false", async () => { + let called = 0; + await $fetch(getURL("408"), { + retry: 2, + retryIf: () => { + called++; + return false; + }, + }).catch(() => "failed"); + expect(called).to.equal(3); + }); + + it("throws if retryIf throws (consumer error) propagates error to consumer", async () => { + let called = 0; + await expect( + $fetch(getURL("408"), { + retry: 2, + retryIf: () => { + called++; + throw new Error("bad predicate"); + }, + }) + ).rejects.toThrow("bad predicate"); + expect(called).to.equal(1); + }); + + it("retries when both retryStatusCodes and retryIf allow", async () => { + let called = 0; + await $fetch(getURL("408"), { + retry: 2, + retryStatusCodes: [408], + retryIf: () => { + called++; + return true; + }, + }).catch(() => "failed"); + expect(called).to.equal(3); + }); + + it("retries when only retryStatusCodes match and retryIf is absent", async () => { + let called = 0; + await $fetch(getURL("408"), { + retry: 2, + retryStatusCodes: [408], + onResponseError: () => { + called++; + }, + }).catch(() => "failed"); + expect(called).to.equal(3); + }); + it("deep merges defaultOptions", async () => { const _customFetch = $fetch.create({ query: {