From 0b15f3eb5431fc90798af89f5ffcae6f80b3b140 Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Sat, 16 May 2026 14:56:17 +0900 Subject: [PATCH] feature: Exponential Backoff And Jitter Signed-off-by: ysknsid25 --- README.md | 23 ++++++++ src/fetch.ts | 47 ++++++++++++---- src/retry.ts | 49 ++++++++++++++++ src/types.ts | 15 ++++- test/index.test.ts | 26 +++++++++ test/retry.test.ts | 135 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 282 insertions(+), 13 deletions(-) create mode 100644 src/retry.ts create mode 100644 test/retry.test.ts diff --git a/README.md b/README.md index ff610bf4..f0c540ec 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,29 @@ await ofetch("http://google.com/404", { }); ``` +### Exponential Backoff with Jitter + +To spread retry attempts over time and reduce thundering-herd effects, set `retryBackoff` instead of a fixed `retryDelay`: + +```ts +await ofetch("http://google.com/api", { + retry: 5, + retryBackoff: { + strategy: "full-jitter", // or "equal-jitter" | "decorrelated-jitter" + base: 100, // minimum delay in ms + cap: 3000, // maximum delay in ms + }, +}); +``` + +Supported strategies (see [AWS: Exponential Backoff And Jitter](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)): + +- `full-jitter` (recommended): `sleep = random(0, min(cap, base * 2^attempt))` +- `equal-jitter`: `temp = min(cap, base * 2^attempt); sleep = temp/2 + random(0, temp/2)` +- `decorrelated-jitter`: `sleep = min(cap, random(base, prev * 3))` + +When `retryBackoff` is set, it takes precedence over `retryDelay`. The current retry count is exposed on the request context as `ctx.retryAttempt` (`undefined` on the first attempt, `1`, `2`, … on retries). + ## ✔️ 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 10c91583..f0ba0ad7 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -8,6 +8,7 @@ import { resolveFetchOptions, callHooks, } from "./utils.ts"; +import { computeBackoffDelay } from "./retry.ts"; import type { CreateFetchOptions, FetchResponse, @@ -18,6 +19,11 @@ import type { FetchOptions, } from "./types.ts"; +interface RetryInternalState { + _retryAttempt?: number; + _retryPrevDelay?: number; +} + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status const retryStatusCodes = new Set([ 408, // Request Timeout @@ -61,18 +67,33 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { ? context.options.retryStatusCodes.includes(responseCode) : retryStatusCodes.has(responseCode)) ) { - const retryDelay = - typeof context.options.retryDelay === "function" - ? context.options.retryDelay(context) - : context.options.retryDelay || 0; + const internal = context.options as RetryInternalState; + let retryDelay = 0; + let nextPrevDelay: number | undefined; + if (context.options.retryBackoff) { + retryDelay = computeBackoffDelay({ + options: context.options.retryBackoff, + attempt: internal._retryAttempt ?? 0, + prevDelay: internal._retryPrevDelay, + }); + nextPrevDelay = retryDelay; + } else { + retryDelay = + typeof context.options.retryDelay === "function" + ? context.options.retryDelay(context) + : context.options.retryDelay || 0; + } if (retryDelay > 0) { await new Promise((resolve) => setTimeout(resolve, retryDelay)); } // Timeout - return $fetchRaw(context.request, { + const nextOptions: FetchOptions & RetryInternalState = { ...context.options, retry: retries - 1, - }); + _retryAttempt: (internal._retryAttempt ?? 0) + 1, + _retryPrevDelay: nextPrevDelay, + }; + return $fetchRaw(context.request, nextOptions); } } @@ -90,16 +111,18 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { T = any, R extends ResponseType = "json", >(_request: FetchRequest, _options: FetchOptions = {}) { + const resolvedOptions = resolveFetchOptions( + _request, + _options, + globalOptions.defaults as unknown as FetchOptions, + Headers + ); const context: FetchContext = { request: _request, - options: resolveFetchOptions( - _request, - _options, - globalOptions.defaults as unknown as FetchOptions, - Headers - ), + options: resolvedOptions, response: undefined, error: undefined, + retryAttempt: (resolvedOptions as RetryInternalState)._retryAttempt, }; // Uppercase method name diff --git a/src/retry.ts b/src/retry.ts new file mode 100644 index 00000000..9e243454 --- /dev/null +++ b/src/retry.ts @@ -0,0 +1,49 @@ +// Exponential backoff + jitter strategies. +// Reference: https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ + +export type RetryBackoffStrategy = + | "full-jitter" + | "equal-jitter" + | "decorrelated-jitter"; + +export interface RetryBackoffOptions { + strategy: RetryBackoffStrategy; + /** Minimum delay in milliseconds. */ + base: number; + /** Maximum delay in milliseconds. */ + cap: number; +} + +export interface ComputeBackoffParams { + options: RetryBackoffOptions; + /** 0-indexed: how many retries have already happened. */ + attempt: number; + /** Previous sleep value, used by decorrelated-jitter. */ + prevDelay?: number; + /** Random source (DI for tests). Defaults to Math.random. */ + random?: () => number; +} + +export function computeBackoffDelay(params: ComputeBackoffParams): number { + const { options, attempt, prevDelay, random = Math.random } = params; + const { strategy, base, cap } = options; + + // Saturate the exponent first to avoid 2 ** large -> Infinity. + const expCap = Math.min(cap, base * 2 ** Math.min(attempt, 31)); + + if (strategy === "full-jitter") { + // sleep = random(0, min(cap, base * 2^attempt)) + return Math.floor(random() * expCap); + } + + if (strategy === "equal-jitter") { + // sleep = temp/2 + random(0, temp/2) + return Math.floor(expCap / 2 + random() * (expCap / 2)); + } + + // decorrelated-jitter: sleep = min(cap, random(base, prev * 3)) + const prev = prevDelay ?? base; + const upper = Math.max(base, prev * 3); + const sleep = base + random() * (upper - base); + return Math.floor(Math.min(cap, sleep)); +} diff --git a/src/types.ts b/src/types.ts index 66a84faf..f260c1db 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,7 @@ +import type { RetryBackoffOptions } from "./retry.ts"; + +export type { RetryBackoffOptions, RetryBackoffStrategy } from "./retry.ts"; + // -------------------------- // $fetch API // -------------------------- @@ -68,6 +72,13 @@ export interface FetchOptions /** Default is [408, 409, 425, 429, 500, 502, 503, 504] */ retryStatusCodes?: number[]; + + /** + * Exponential backoff with jitter for retry delays. + * When set, takes precedence over `retryDelay`. + * @see https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ + */ + retryBackoff?: RetryBackoffOptions; } export interface ResolvedFetchOptions< @@ -84,7 +95,7 @@ export interface CreateFetchOptions { export type GlobalOptions = Pick< FetchOptions, - "timeout" | "retry" | "retryDelay" + "timeout" | "retry" | "retryDelay" | "retryBackoff" >; // -------------------------- @@ -96,6 +107,8 @@ export interface FetchContext { options: ResolvedFetchOptions; response?: FetchResponse; error?: Error; + /** Number of retries already performed before this attempt. `undefined` on the first attempt. */ + retryAttempt?: number; } type MaybePromise = T | Promise; diff --git a/test/index.test.ts b/test/index.test.ts index 5ac20b07..2105d86a 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -295,6 +295,32 @@ describe("ofetch", () => { expect(race).to.equal("fast"); }); + it("retry with retryBackoff (equal-jitter)", async () => { + const onRequest = vi.fn(); + const error = await $fetch(getURL("408"), { + retry: 2, + retryBackoff: { strategy: "equal-jitter", base: 1, cap: 5 }, + onRequest, + }).catch((error_: any) => error_); + expect(error.status).toBe(408); + // initial request + 2 retries + expect(onRequest).toHaveBeenCalledTimes(3); + // retryAttempt is undefined on first call, then 1, 2 + const attempts = onRequest.mock.calls.map(([ctx]) => ctx.retryAttempt); + expect(attempts).toEqual([undefined, 1, 2]); + }); + + it("retryBackoff takes precedence over retryDelay", async () => { + const retryDelay = vi.fn(() => 0); + const error = await $fetch(getURL("408"), { + retry: 1, + retryDelay, + retryBackoff: { strategy: "decorrelated-jitter", base: 1, cap: 5 }, + }).catch((error_: any) => error_); + expect(error.status).toBe(408); + expect(retryDelay).not.toHaveBeenCalled(); + }); + it("abort with retry", async () => { const controller = new AbortController(); async function abortHandle() { diff --git a/test/retry.test.ts b/test/retry.test.ts new file mode 100644 index 00000000..e2c60f84 --- /dev/null +++ b/test/retry.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { computeBackoffDelay } from "../src/retry.ts"; + +describe("computeBackoffDelay", () => { + describe("full-jitter", () => { + const options = { + strategy: "full-jitter" as const, + base: 100, + cap: 3000, + }; + + it("random=0 returns 0", () => { + expect( + computeBackoffDelay({ options, attempt: 0, random: () => 0 }) + ).toBe(0); + }); + + it("random~=1 approaches expCap", () => { + // expCap = min(3000, 100 * 2^0) = 100; sleep = 0.999 * 100 = 99 + expect( + computeBackoffDelay({ options, attempt: 0, random: () => 0.999 }) + ).toBe(99); + }); + + it("upper bound grows exponentially within cap", () => { + // attempt=3 -> expCap = min(3000, 800) = 800 + const high = computeBackoffDelay({ + options, + attempt: 3, + random: () => 0.999, + }); + expect(high).toBeGreaterThanOrEqual(799 - 1); + expect(high).toBeLessThan(800); + }); + + it("saturates at cap for large attempt", () => { + const high = computeBackoffDelay({ + options, + attempt: 50, + random: () => 0.999, + }); + expect(high).toBeLessThanOrEqual(options.cap); + expect(high).toBeGreaterThan(options.cap - 5); + }); + }); + + describe("equal-jitter", () => { + const options = { + strategy: "equal-jitter" as const, + base: 100, + cap: 3000, + }; + + it("attempt=0 with random=0 returns base/2", () => { + // temp = min(3000, 100 * 2^0) = 100; sleep = 50 + 0 = 50 + expect( + computeBackoffDelay({ options, attempt: 0, random: () => 0 }) + ).toBe(50); + }); + + it("attempt=0 with random~=1 returns just under base", () => { + // temp = 100; sleep = 50 + 0.999 * 50 ~= 99 + expect( + computeBackoffDelay({ options, attempt: 0, random: () => 0.999 }) + ).toBe(99); + }); + + it("grows exponentially within cap", () => { + // attempt=3 -> temp = min(3000, 100*8) = 800; sleep range [400, 800) + const mid = computeBackoffDelay({ + options, + attempt: 3, + random: () => 0.5, + }); + expect(mid).toBeGreaterThanOrEqual(400); + expect(mid).toBeLessThan(800); + }); + + it("saturates at cap", () => { + // very large attempt should still respect cap; max sleep == cap + const high = computeBackoffDelay({ + options, + attempt: 50, + random: () => 0.999, + }); + expect(high).toBeLessThanOrEqual(options.cap); + expect(high).toBeGreaterThanOrEqual(options.cap / 2 - 1); + }); + }); + + describe("decorrelated-jitter", () => { + const options = { + strategy: "decorrelated-jitter" as const, + base: 100, + cap: 3000, + }; + + it("first call (no prevDelay) uses base as prev", () => { + // upper = max(100, 100*3) = 300; sleep = 100 + 0 = 100 + expect( + computeBackoffDelay({ options, attempt: 0, random: () => 0 }) + ).toBe(100); + }); + + it("first call upper bound is base*3", () => { + // sleep = 100 + 0.999 * (300 - 100) ~= 299 + expect( + computeBackoffDelay({ options, attempt: 0, random: () => 0.999 }) + ).toBe(299); + }); + + it("grows from prevDelay", () => { + // prev=500 -> upper = 1500; sleep at random=0.5 = 100 + 700 = 800 + expect( + computeBackoffDelay({ + options, + attempt: 1, + prevDelay: 500, + random: () => 0.5, + }) + ).toBe(800); + }); + + it("saturates at cap", () => { + expect( + computeBackoffDelay({ + options, + attempt: 10, + prevDelay: 5000, + random: () => 0.999, + }) + ).toBe(options.cap); + }); + }); +});