diff --git a/package-lock.json b/package-lock.json index eb86a5c..2ee4672 100644 --- a/package-lock.json +++ b/package-lock.json @@ -92,29 +92,6 @@ "node": ">=18" } }, - "node_modules/@emnapi/core": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", - "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "2.0.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", - "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", @@ -1211,6 +1188,7 @@ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1262,6 +1240,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -1428,6 +1407,7 @@ "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", @@ -1582,6 +1562,7 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2374,6 +2355,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4275,6 +4257,7 @@ "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "=0.142.0", "@rolldown/pluginutils": "^1.0.0" @@ -4309,6 +4292,7 @@ "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -4835,7 +4819,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tweetnacl": { "version": "1.0.3", @@ -4891,6 +4876,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4929,6 +4915,7 @@ "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", @@ -5022,6 +5009,7 @@ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", diff --git a/src/index.ts b/src/index.ts index e6d7459..403282e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ export type { BatchExecuteAsyncOptions, BatchResult, } from './builder.js'; +export { withRetry, isTransientRpcError } from './with-retry.js'; +export type { WithRetryOptions } from './with-retry.js'; export { buildBatchTransactions, buildBatchTransactionsSync, diff --git a/src/tests/with-retry.test.ts b/src/tests/with-retry.test.ts new file mode 100644 index 0000000..8d0f3ec --- /dev/null +++ b/src/tests/with-retry.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { withRetry, isTransientRpcError } from '../with-retry.js'; +import { + RateLimitError, + StreamFiNetworkError, + RpcServiceUnavailableError, +} from '../errors.js'; + +describe('withRetry', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns immediately on success', async () => { + const operation = vi.fn().mockResolvedValue('ok'); + await expect(withRetry(operation)).resolves.toBe('ok'); + expect(operation).toHaveBeenCalledTimes(1); + }); + + it('retries on RateLimitError and honours Retry-After', async () => { + const operation = vi + .fn() + .mockRejectedValueOnce(new RateLimitError('slow down', 2_000)) + .mockResolvedValueOnce('ok'); + + const promise = withRetry(operation); + promise.catch(() => {}); + + await vi.advanceTimersByTimeAsync(1_999); + expect(operation).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + await expect(promise).resolves.toBe('ok'); + expect(operation).toHaveBeenCalledTimes(2); + }); + + it('falls back to exponential backoff when no Retry-After is present', async () => { + const operation = vi + .fn() + .mockRejectedValueOnce(new RateLimitError('slow down')) + .mockRejectedValueOnce(new RateLimitError('still slow')) + .mockResolvedValueOnce('ok'); + + const promise = withRetry(operation, { baseDelayMs: 100, backoffFactor: 2 }); + promise.catch(() => {}); + + await vi.advanceTimersByTimeAsync(99); + expect(operation).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + expect(operation).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(199); + expect(operation).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(1); + await expect(promise).resolves.toBe('ok'); + expect(operation).toHaveBeenCalledTimes(3); + }); + + it('gives up after maxRetries and throws the last error', async () => { + const err = new RateLimitError('slow down'); + const operation = vi.fn().mockRejectedValue(err); + + const promise = withRetry(operation, { maxRetries: 2, baseDelayMs: 10 }); + promise.catch(() => {}); + + await vi.advanceTimersByTimeAsync(100); + await expect(promise).rejects.toBe(err); + expect(operation).toHaveBeenCalledTimes(3); // initial + 2 retries + }); + + it('throws non-retryable errors immediately', async () => { + const err = new Error('boom'); + const operation = vi.fn().mockRejectedValue(err); + + await expect(withRetry(operation)).rejects.toBe(err); + expect(operation).toHaveBeenCalledTimes(1); + }); + + it('respects a custom shouldRetry predicate', async () => { + const err = new Error('transient'); + const operation = vi + .fn() + .mockRejectedValueOnce(err) + .mockResolvedValueOnce('ok'); + + const promise = withRetry(operation, { + shouldRetry: (e) => e instanceof Error && e.message === 'transient', + baseDelayMs: 50, + }); + promise.catch(() => {}); + + await vi.advanceTimersByTimeAsync(50); + await expect(promise).resolves.toBe('ok'); + }); + + it('caps the delay at maxDelayMs', async () => { + const operation = vi + .fn() + .mockRejectedValueOnce(new RateLimitError('1')) + .mockRejectedValueOnce(new RateLimitError('2')) + .mockRejectedValueOnce(new RateLimitError('3')) + .mockResolvedValueOnce('ok'); + + const promise = withRetry(operation, { + baseDelayMs: 100, + backoffFactor: 10, + maxDelayMs: 500, + }); + promise.catch(() => {}); + + // First retry: 100 ms + await vi.advanceTimersByTimeAsync(100); + expect(operation).toHaveBeenCalledTimes(2); + + // Second retry would be 1000 ms, but capped to 500 ms + await vi.advanceTimersByTimeAsync(499); + expect(operation).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(1); + expect(operation).toHaveBeenCalledTimes(3); + + // Third retry also capped to 500 ms + await vi.advanceTimersByTimeAsync(500); + await expect(promise).resolves.toBe('ok'); + expect(operation).toHaveBeenCalledTimes(4); + }); + + it('calls onRetry before each retry', async () => { + const onRetry = vi.fn(); + const operation = vi + .fn() + .mockRejectedValueOnce(new RateLimitError('slow')) + .mockResolvedValueOnce('ok'); + + const promise = withRetry(operation, { + baseDelayMs: 100, + onRetry, + }); + promise.catch(() => {}); + + expect(onRetry).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(100); + + expect(onRetry).toHaveBeenCalledTimes(1); + expect(onRetry).toHaveBeenCalledWith({ + attempt: 1, + delayMs: 100, + error: expect.any(RateLimitError), + }); + + await expect(promise).resolves.toBe('ok'); + }); + + it('can be aborted before the first attempt', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + withRetry(vi.fn().mockResolvedValue('ok'), { signal: controller.signal }), + ).rejects.toThrow('withRetry aborted before first attempt'); + }); + + it('can be aborted while waiting to retry', async () => { + const controller = new AbortController(); + const operation = vi.fn().mockRejectedValue(new RateLimitError('slow')); + + const promise = withRetry(operation, { + signal: controller.signal, + baseDelayMs: 100, + }); + promise.catch(() => {}); + + await vi.advanceTimersByTimeAsync(50); + controller.abort(); + + await expect(promise).rejects.toThrow('withRetry sleep aborted'); + }); + + it('applies full jitter within the delay window', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + + const operation = vi + .fn() + .mockRejectedValueOnce(new RateLimitError('slow')) + .mockResolvedValueOnce('ok'); + + const onRetry = vi.fn(); + const promise = withRetry(operation, { + baseDelayMs: 100, + jitter: 'full', + onRetry, + }); + promise.catch(() => {}); + + await vi.advanceTimersByTimeAsync(50); + await expect(promise).resolves.toBe('ok'); + + // full jitter: Math.floor(0.5 * (100 + 1)) = 50 + expect(onRetry).toHaveBeenCalledWith( + expect.objectContaining({ delayMs: 50 }), + ); + + vi.restoreAllMocks(); + }); + + it('applies equal jitter around half the delay', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + + const operation = vi + .fn() + .mockRejectedValueOnce(new RateLimitError('slow')) + .mockResolvedValueOnce('ok'); + + const onRetry = vi.fn(); + const promise = withRetry(operation, { + baseDelayMs: 100, + jitter: 'equal', + onRetry, + }); + promise.catch(() => {}); + + await vi.advanceTimersByTimeAsync(75); + await expect(promise).resolves.toBe('ok'); + + // equal jitter: Math.floor(50 + 0.5 * 50) = 75 + expect(onRetry).toHaveBeenCalledWith( + expect.objectContaining({ delayMs: 75 }), + ); + + vi.restoreAllMocks(); + }); +}); + +describe('isTransientRpcError', () => { + it('matches transient RPC error classes', () => { + expect(isTransientRpcError(new RateLimitError('slow'))).toBe(true); + expect(isTransientRpcError(new StreamFiNetworkError('offline'))).toBe(true); + expect( + isTransientRpcError(new RpcServiceUnavailableError('down')), + ).toBe(true); + expect(isTransientRpcError(new Error('boom'))).toBe(false); + }); +}); diff --git a/src/with-retry.ts b/src/with-retry.ts index 9177cd8..a139b45 100644 --- a/src/with-retry.ts +++ b/src/with-retry.ts @@ -1,4 +1,4 @@ -import { RateLimitError } from './errors.js'; +import { RateLimitError, StreamFiNetworkError, RpcServiceUnavailableError } from './errors.js'; export interface WithRetryOptions { /** Maximum number of retry attempts after the initial failure. Default: 3 */ @@ -7,15 +7,30 @@ export interface WithRetryOptions { baseDelayMs?: number; /** Backoff multiplier applied after each retry. Default: 2 */ backoffFactor?: number; + /** Maximum delay in milliseconds; exponential growth is clamped here. Default: 30_000 */ + maxDelayMs?: number; + /** Jitter strategy applied to each delay. Default: 'none' */ + jitter?: 'none' | 'full' | 'equal'; + /** Predicate that decides whether a thrown error is worth retrying. */ + shouldRetry?: (error: unknown) => boolean; + /** Optional AbortSignal for cancelling pending retries. */ + signal?: AbortSignal; + /** Optional callback invoked before each retry. */ + onRetry?: (info: { attempt: number; delayMs: number; error: unknown }) => void; } /** - * Retry an async operation when it throws a RateLimitError. + * Retry an async operation with exponential backoff and optional jitter. * - * Honours the server Retry-After header (exposed on the error as - * retryAfterMs) by waiting at least that long before the next attempt. - * When no Retry-After is provided, falls back to exponential backoff - * starting at baseDelayMs. + * By default the helper retries only {@link RateLimitError}, honouring the + * server's `Retry-After` header (exposed on the error as `retryAfterMs`) by + * waiting at least that long before the next attempt. When no `Retry-After` + * is provided, it falls back to exponential backoff starting at + * `baseDelayMs`. + * + * The predicate, jitter strategy, delay cap, and `AbortSignal` make the + * helper usable beyond the SDK's RPC proxy — for example, for transient + * network errors or any caller-defined retryable condition. */ export async function withRetry( operation: () => Promise, @@ -24,20 +39,43 @@ export async function withRetry( const maxRetries = options.maxRetries ?? 3; const baseDelayMs = options.baseDelayMs ?? 500; const backoffFactor = options.backoffFactor ?? 2; + const maxDelayMs = options.maxDelayMs ?? 30_000; + const jitter = options.jitter ?? 'none'; + const shouldRetry = options.shouldRetry ?? isRateLimitError; + const signal = options.signal; + const onRetry = options.onRetry; + + if (signal?.aborted) { + throw new Error('withRetry aborted before first attempt'); + } let delay = baseDelayMs; for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + if (signal?.aborted) { + throw new Error('withRetry aborted'); + } + try { return await operation(); } catch (err) { - const classified = RateLimitError.fromRpcError(err); - if (!(classified instanceof RateLimitError) || attempt === maxRetries) { - throw classified ?? err; + // Classify raw RPC errors so the default predicate and callers receive + // typed RateLimitError / RpcServiceUnavailableError instances. + const classified = RateLimitError.fromRpcError(err) ?? err; + + if (attempt === maxRetries || !shouldRetry(classified)) { + throw classified; } - const waitTime = classified.retryAfterMs ?? delay; - await sleep(waitTime); - delay *= backoffFactor; + + const retryAfterMs = classified instanceof RateLimitError ? classified.retryAfterMs : undefined; + const baseWait = retryAfterMs ?? delay; + const clampedWait = Math.min(baseWait, maxDelayMs); + const waitTime = applyJitter(clampedWait, jitter); + + onRetry?.({ attempt: attempt + 1, delayMs: waitTime, error: classified }); + await sleep(waitTime, signal); + + delay = Math.min(delay * backoffFactor, maxDelayMs); } } @@ -45,6 +83,45 @@ export async function withRetry( throw new Error('withRetry exhausted all retries'); } -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); +function isRateLimitError(err: unknown): boolean { + return err instanceof RateLimitError; +} + +function applyJitter(delayMs: number, strategy: NonNullable): number { + switch (strategy) { + case 'full': + return Math.floor(Math.random() * (delayMs + 1)); + case 'equal': { + const half = delayMs / 2; + return Math.floor(half + Math.random() * half); + } + case 'none': + default: + return delayMs; + } +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(new Error('withRetry sleep aborted')); + }, { once: true }); + }); +} + +/** + * Convenience predicate: retry transient network-level failures. + * + * Matches {@link RateLimitError}, {@link StreamFiNetworkError}, and + * {@link RpcServiceUnavailableError} so callers can compose a retry + * policy for RPC calls without hard-coding error classes. + */ +export function isTransientRpcError(err: unknown): boolean { + return ( + err instanceof RateLimitError || + err instanceof StreamFiNetworkError || + err instanceof RpcServiceUnavailableError + ); }