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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
47 changes: 35 additions & 12 deletions src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
resolveFetchOptions,
callHooks,
} from "./utils.ts";
import { computeBackoffDelay } from "./retry.ts";
import type {
CreateFetchOptions,
FetchResponse,
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -90,16 +111,18 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
T = any,
R extends ResponseType = "json",
>(_request: FetchRequest, _options: FetchOptions<R> = {}) {
const resolvedOptions = resolveFetchOptions<R, T>(
_request,
_options,
globalOptions.defaults as unknown as FetchOptions<R, T>,
Headers
);
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,
retryAttempt: (resolvedOptions as RetryInternalState)._retryAttempt,
};

// Uppercase method name
Expand Down
49 changes: 49 additions & 0 deletions src/retry.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +11 to +14
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clarify base semantics in API docs.

base is not a strict minimum delay for full-jitter and equal-jitter (those can go below base, including 0). Please reword this to avoid misleading users.

Suggested wording
-  /** Minimum delay in milliseconds. */
+  /** Base delay in milliseconds used by backoff calculations. */
   base: number;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** Minimum delay in milliseconds. */
base: number;
/** Maximum delay in milliseconds. */
cap: number;
/** Base delay in milliseconds used by backoff calculations. */
base: number;
/** Maximum delay in milliseconds. */
cap: number;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/retry.ts` around lines 11 - 14, The JSDoc for the `base` field is
misleading: update the comment for `base` (in src/retry.ts) to explain it is the
base/backoff factor used to compute retry delays rather than a guaranteed
minimum delay — explicitly state that for jitter strategies like `full-jitter`
and `equal-jitter` computed delays can be below `base` (even 0); keep `cap`
description as the upper bound. Mention the relevant symbols `base`, `cap`, and
the jitter strategies (`full-jitter`, `equal-jitter`) so readers understand the
semantics.

}

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));
}
15 changes: 14 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import type { RetryBackoffOptions } from "./retry.ts";

export type { RetryBackoffOptions, RetryBackoffStrategy } from "./retry.ts";

// --------------------------
// $fetch API
// --------------------------
Expand Down Expand Up @@ -68,6 +72,13 @@ export interface FetchOptions<R extends ResponseType = ResponseType, T = any>

/** 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<
Expand All @@ -84,7 +95,7 @@ export interface CreateFetchOptions {

export type GlobalOptions = Pick<
FetchOptions,
"timeout" | "retry" | "retryDelay"
"timeout" | "retry" | "retryDelay" | "retryBackoff"
>;

// --------------------------
Expand All @@ -96,6 +107,8 @@ export interface FetchContext<T = any, R extends ResponseType = ResponseType> {
options: ResolvedFetchOptions<R>;
response?: FetchResponse<T>;
error?: Error;
/** Number of retries already performed before this attempt. `undefined` on the first attempt. */
retryAttempt?: number;
}

type MaybePromise<T> = T | Promise<T>;
Expand Down
26 changes: 26 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
135 changes: 135 additions & 0 deletions test/retry.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading