Skip to content

Commit

Permalink
move fetch retry client to core and add better timeout and listener c…
Browse files Browse the repository at this point in the history
…leanup
  • Loading branch information
dillonstreator committed Jan 21, 2025
1 parent 808df13 commit ff2292c
Show file tree
Hide file tree
Showing 5 changed files with 91 additions and 28 deletions.
Original file line number Diff line number Diff line change
@@ -1,29 +1,65 @@
import { sleep } from '@crypt.fyi/core';
import { sleepWithCancel } from './sleep';

/**
* Creates a fetch client with retry capabilities and configurable timeout behavior.
*
* @remarks
* This client handles two distinct types of timeouts/cancellations:
* 1. Internal timeout: Controlled by `requestTimeoutMs`. When triggered, the request will be retried
* if within `maxAttempts`. Uses a custom `TimeoutError` to distinguish from other abort events.
* 2. External cancellation: Via caller-provided AbortSignal in fetch options. When triggered,
* immediately stops execution without retry attempts.
*
* The client uses exponential backoff between retry attempts, with configurable delay parameters.
*
* @param options - Configuration options for the retry client
* @param options.maxAttempts - Maximum number of attempts before giving up
* @param options.requestTimeoutMs - Timeout for each individual request attempt (default: 5000ms)
* @param options.fetchFn - Optional custom fetch implementation (default: global fetch)
* @param options.shouldRetry - Optional custom function to determine if an error is retryable (default: isRetryableFetchError)
* @param options.calculateBackoff - Optional custom function to calculate the backoff delay between retries (default: defaultCalculateBackoff)
*
* @returns A fetch-compatible function that implements the retry logic
*
* @throws {FetchRetryError} When all retry attempts fail or a non-retryable error occurs
*
* @example
* ```ts
* const client = createFetchRetryClient({
* maxAttempts: 3,
* requestTimeoutMs: 2000
* });
*
* // With caller cancellation
* const controller = new AbortController();
* const response = await client('https://api.example.com', {
* signal: controller.signal
* });
* ```
*/
export const createFetchRetryClient = ({
maxAttempts,
requestTimeoutMs = 5_000,
backoffDelayMs = 5_000,
backoffFactor = 2,
backoffMaxDelayMs = 60_000,
fetchFn = fetch,
shouldRetry = isRetryableFetchError,
calculateBackoff = defaultCalculateBackoff,
}: {
maxAttempts: number;
requestTimeoutMs?: number;
backoffDelayMs?: number;
backoffFactor?: number;
backoffMaxDelayMs?: number;
shouldRetry?: (error: unknown) => boolean;
calculateBackoff?: (attempt: number) => number;
fetchFn?: typeof fetch;
}): typeof fetch => {
return async (url, options) => {
let attempts = 0;
let errors: unknown[] = [];
const errors: unknown[] = [];

do {
const signals = [options?.signal];
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (requestTimeoutMs > 0) {
// We intentionally don't use AbortSignal.timeout because we want to control the abort reason with our sentinel TimeoutError
// We intentionally don't use AbortSignal.timeout because we want to control the abort reason with our own sentinel `TimeoutError`
// This allows our internal timeout to be retryable by default and a cancellation from the caller to be respected and not trigger a retry
// Maybe monkey patch AbortSignal.timeout to allow an optional sentinel error or create a helper function for this pattern
const ab = new AbortController();
timeoutId = setTimeout(() => ab.abort(new TimeoutError()), requestTimeoutMs);
Expand All @@ -41,24 +77,26 @@ export const createFetchRetryClient = ({
} catch (error) {
errors.push(error);

if (!isRetryableFetchError(error)) {
if (!shouldRetry(error)) {
throw new FetchRetryError(errors, 'Non-retryable error occurred');
}
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
const backoffMs = calculateBackoff(attempts, {
delayMs: backoffDelayMs,
factor: backoffFactor,
maxDelayMs: backoffMaxDelayMs,
});
const backoffMs = calculateBackoff(attempts);

const sleeper = sleepWithCancel(backoffMs);
const eventListenerAC = new AbortController();
await Promise.race([
sleep(backoffMs),
new Promise((resolve) => signal.addEventListener('abort', resolve)),
sleeper.promise,
new Promise((resolve) =>
signal.addEventListener('abort', resolve, { signal: eventListenerAC.signal, once: true }),
),
]);
sleeper.cancel();
eventListenerAC.abort();
} while (++attempts < maxAttempts);

throw new FetchRetryError(errors, 'Failed to fetch after max retries');
Expand Down Expand Up @@ -87,15 +125,12 @@ const isRetryableStatusCode = (status: number): boolean => {
return [408, 423, 425].includes(status);
};

const calculateBackoff = (
attempt: number,
config: { delayMs: number; factor: number; maxDelayMs: number },
): number => {
const backoffMs = config.delayMs * Math.pow(config.factor, attempt - 1);
return Math.min(backoffMs, config.maxDelayMs);
const defaultCalculateBackoff = (attempt: number): number => {
const backoffMs = 5_000 * Math.pow(2, attempt - 1);
return Math.min(backoffMs, 60_000);
};

class HTTPError extends Error {
export class HTTPError extends Error {
status: number;
constructor(status: number, message: string) {
super(`${status} ${message}`);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from './hash';
export * from './random';
export * from './encryption';
export * from './sleep';
export * from './fetchRetry';
29 changes: 29 additions & 0 deletions packages/core/src/sleep.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,31 @@
export const sleep = (ms: number, { enabled = true }: { enabled?: boolean } = {}): Promise<void> =>
enabled ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();

export type CancellableSleep = {
promise: Promise<void>;
cancel: () => void;
};

export const sleepWithCancel = (
ms: number,
{ enabled = true }: { enabled?: boolean } = {},
): CancellableSleep => {
if (!enabled) {
return {
promise: Promise.resolve(),
cancel: () => {}, // No-op for disabled sleep
};
}

let timeoutId: ReturnType<typeof setTimeout>;
const promise = new Promise<void>((resolve) => {
timeoutId = setTimeout(resolve, ms);
});

return {
promise,
cancel: () => {
clearTimeout(timeoutId);
},
};
};
3 changes: 1 addition & 2 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Redis from 'ioredis';
import { createRedisVault } from './vault/redis';
import { createTokenGenerator } from './vault/tokens';
import { createBullMQWebhookSender, createHTTPWebhookSender, WebhookSender } from './webhook';
import { createFetchRetryClient } from './fetchRetry';
import { createFetchRetryClient } from '@crypt.fyi/core';

const main = async () => {
const logger = await initLogging(config);
Expand All @@ -21,7 +21,6 @@ const main = async () => {
let webhookSender: WebhookSender;
const webhookSenderFetch = createFetchRetryClient({
maxAttempts: config.webhookMaxAttempts,
backoffDelayMs: config.webhookBackoffDelayMs,
});
if (config.webhookSender === 'bullmq') {
const bullmqRedis = new Redis(config.redisUrl, { maxRetriesPerRequest: null, family: 0 });
Expand Down
3 changes: 1 addition & 2 deletions packages/server/src/webhook.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { Queue, Worker } from 'bullmq';
import { gcm } from '@crypt.fyi/core';
import { gcm, isRetryableFetchError } from '@crypt.fyi/core';
import { Logger } from './logging';
import Redis from 'ioredis';
import { z } from 'zod';
import { isRetryableFetchError } from './fetchRetry';

const webhookEventSchema = z.enum(['READ', 'BURN', 'FAILURE_KEY_PASSWORD', 'FAILURE_IP_ADDRESS']);

Expand Down

0 comments on commit ff2292c

Please sign in to comment.