Skip to content

Commit

Permalink
Merge branch 'thejoekingmann-feat/adds-exponential-backoff'
Browse files Browse the repository at this point in the history
  • Loading branch information
Bekacru committed Jul 19, 2024
2 parents cd8118f + 7d000df commit 600abd2
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 24 deletions.
25 changes: 13 additions & 12 deletions src/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ZodSchema, type z } from "zod";
import { BetterFetchError } from "./error";
import { initializePlugins } from "./plugins";
import { createRetryStrategy } from "./retry";
import type { BetterFetchOption, BetterFetchResponse } from "./types";
import {
detectResponseType,
Expand Down Expand Up @@ -147,19 +148,19 @@ export const betterFetch = async <
await onError(errorContext);
}
}
if (options?.retry && options.retry.count > 0) {
if (options.retry.interval) {
await new Promise((resolve) =>
setTimeout(resolve, options.retry?.interval),
);

if (options?.retry) {
const retryStrategy = createRetryStrategy(options.retry);
const _retryAttempt = options.retryAttempt ?? 0;
if (await retryStrategy.shouldAttemptRetry(_retryAttempt, response)) {
await options?.onRetry?.(responseContext);
const delay = retryStrategy.getDelay(_retryAttempt);
await new Promise((resolve) => setTimeout(resolve, delay));
return await betterFetch(url, {
...options,
retryAttempt: _retryAttempt + 1,
});
}
return await betterFetch(url, {
...options,
retry: {
count: options.retry.count - 1,
interval: options.retry.interval,
},
});
}

const parser = options?.jsonParser ?? jsonParse;
Expand Down
5 changes: 5 additions & 0 deletions src/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export interface FetchHooks {
* error occurs.
*/
onError?: (context: ErrorContext) => Promise<void> | void;
/**
* a callback function that will be called when a
* request is retried.
*/
onRetry?: (response: ResponseContext) => Promise<void> | void;
}

/**
Expand Down
91 changes: 91 additions & 0 deletions src/retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
export type RetryCondition = (
response: Response | null,
) => boolean | Promise<boolean>;

export type LinearRetry = {
type: "linear";
attempts: number;
delay: number;
shouldRetry?: RetryCondition;
};

export type ExponentialRetry = {
type: "exponential";
attempts: number;
baseDelay: number;
maxDelay: number;
shouldRetry?: RetryCondition;
};

export type RetryOptions = LinearRetry | ExponentialRetry | number;

export interface RetryStrategy {
shouldAttemptRetry(
attempt: number,
response: Response | null,
): Promise<boolean>;
getDelay(attempt: number): number;
}

class LinearRetryStrategy implements RetryStrategy {
constructor(private options: LinearRetry) {}

shouldAttemptRetry(
attempt: number,
response: Response | null,
): Promise<boolean> {
if (this.options.shouldRetry) {
return Promise.resolve(
attempt < this.options.attempts && this.options.shouldRetry(response),
);
}
return Promise.resolve(attempt < this.options.attempts);
}

getDelay(): number {
return this.options.delay;
}
}

class ExponentialRetryStrategy implements RetryStrategy {
constructor(private options: ExponentialRetry) {}

shouldAttemptRetry(
attempt: number,
response: Response | null,
): Promise<boolean> {
if (this.options.shouldRetry) {
return Promise.resolve(
attempt < this.options.attempts && this.options.shouldRetry(response),
);
}
return Promise.resolve(attempt < this.options.attempts);
}

getDelay(attempt: number): number {
const delay = Math.min(
this.options.maxDelay,
this.options.baseDelay * 2 ** attempt,
);
return delay;
}
}

export function createRetryStrategy(options: RetryOptions): RetryStrategy {
if (typeof options === "number") {
return new LinearRetryStrategy({
type: "linear",
attempts: options,
delay: 1000,
});
}

switch (options.type) {
case "linear":
return new LinearRetryStrategy(options);
case "exponential":
return new ExponentialRetryStrategy(options);
default:
throw new Error("Invalid retry strategy");
}
}
63 changes: 58 additions & 5 deletions src/test/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,70 @@ describe("fetch", () => {
it("should retry on error", async () => {
let count = 0;
await betterFetch(getURL("error"), {
retry: 3,
onError() {
count++;
},
});
expect(count).toBe(4);
});

it("should retry with linear delay", async () => {
let count = 0;

const beforeCall = Date.now();
let lastCallTime = 0;

const fetchPromise = betterFetch(getURL("error"), {
retry: {
count: 3,
interval: 0,
type: "linear",
attempts: 3,
delay: 200,
},
onError() {
count++;
lastCallTime = Date.now();
},
});

await fetchPromise;

expect(count).toBe(4);
expect(lastCallTime - beforeCall).toBeGreaterThanOrEqual(200 * 3);
});

it("should retry with exponential backoff and increasing delays", async () => {
let count = 0;
const delays: number[] = [];
let lastCallTime = 0;

const fetchPromise = betterFetch(getURL("error"), {
retry: {
type: "exponential",
attempts: 3,
baseDelay: 100,
maxDelay: 1000,
},
onError() {
count++;
const currentTime = Date.now();
if (lastCallTime > 0) {
delays.push(currentTime - lastCallTime);
}
lastCallTime = currentTime;
},
});

await fetchPromise;

expect(count).toBe(4);

expect(delays[1]).toBeGreaterThan(delays[0]);
expect(delays[2]).toBeGreaterThan(delays[1]);

expect(delays[0]).toBeGreaterThanOrEqual(100);
expect(delays[1]).toBeGreaterThanOrEqual(200);
expect(delays[2]).toBeGreaterThanOrEqual(400);
});

it("abort with retry", () => {
Expand All @@ -159,9 +214,7 @@ describe("fetch", () => {
controller.abort();
const response = await betterFetch("", {
baseURL: getURL("ok"),
retry: {
count: 3,
},
retry: 3,
signal: controller.signal,
});
}
Expand Down
13 changes: 6 additions & 7 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ZodSchema } from "zod";
import type { Auth } from "./auth";
import type { BetterFetchPlugin, FetchHooks } from "./plugins";
import type { RetryOptions } from "./retry";
import type { StringLiteralUnion } from "./type-utils";

type CommonHeaders = {
Expand Down Expand Up @@ -91,13 +92,11 @@ export type BetterFetchOption<
/**
* Retry count
*/
retry?: {
count: number;
/**
* Retry interval in milliseconds
*/
interval?: number;
};
retry?: RetryOptions;
/**
* the number of times the request has alredy been retried
*/
retryAttempt?: number;
/**
* HTTP method
*/
Expand Down

0 comments on commit 600abd2

Please sign in to comment.