Add retry with backoff to api-client

PR #482 · feat/client-retrymain · 2 files · +58 −9 · reviewing for correctness and failure behaviour

Motivation

Payments calls fail transiently under load (about 0.4% of requests, mostly 503s from the gateway). Today every failure surfaces to the caller. This PR wraps request() in a retry loop with exponential backoff and jitter, opt-in per call.

Where to focus

  1. Which errors are retried (note 2).
  2. The idempotency guard (note 3).
  3. Timeout budget across attempts (note 4).
src/client/retry.tsnew file · +41
@@ -0,0 +1,41 @@
1+export interface RetryOptions {
2+ attempts?: number; // total attempts, default 3
3+ baseMs?: number; // first delay, default 200
4+ maxMs?: number; // cap, default 2000
5+ retryOn?: (err: unknown) => boolean;
6+}
7+
8+const defaultRetryOn = (err: unknown) => true;
9+
10+export async function withRetry<T>(
11+ fn: () => Promise<T>,
12+ opts: RetryOptions = {},
13+): Promise<T> {
14+ const attempts = opts.attempts ?? 3;
15+ const base = opts.baseMs ?? 200;
16+ const max = opts.maxMs ?? 2000;
17+ const retryOn = opts.retryOn ?? defaultRetryOn;
18+ let lastErr: unknown;
19+ for (let i = 0; i < attempts; i++) {
20+ try {
21+ return await fn();
22+ } catch (err) {
23+ lastErr = err;
24+ if (i === attempts - 1 || !retryOn(err)) break;
25+ const delay = Math.min(max, base * 2 ** i) * (0.5 + Math.random());
26+ await new Promise(r => setTimeout(r, delay));
27+ }
28+ }
29+ throw lastErr;
30+}
src/client/request.ts+17 −9
@@ -1,6 +1,7 @@
11 import { ApiError } from './errors';
2+import { withRetry, type RetryOptions } from './retry';
23
3-export async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
4+export interface RequestOptions extends RequestInit { retry?: RetryOptions | false; timeoutMs?: number }
5+
6+export async function request<T>(path: string, init: RequestOptions = {}): Promise<T> {
@@ -12,9 +13,17 @@
12- const ctrl = new AbortController();
13- const t = setTimeout(() => ctrl.abort(), 8000);
14- const res = await fetch(base + path, { ...init, signal: ctrl.signal });
15- clearTimeout(t);
16- if (!res.ok) throw new ApiError(res.status, await res.text());
17- return res.json();
13+ const { retry, timeoutMs = 8000, ...rest } = init;
14+ const once = async () => {
15+ const ctrl = new AbortController();
16+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
17+ try {
18+ const res = await fetch(base + path, { ...rest, signal: ctrl.signal });
19+ if (!res.ok) throw new ApiError(res.status, await res.text());
20+ return (await res.json()) as T;
21+ } finally { clearTimeout(t); }
22+ };
23+ return retry === false ? once() : withRetry(once, retry ?? {});
1824 }

Verdict: request changes

The mechanics are right (jitter, cap, cleanup in finally). The policy is wrong: retrying everything by default, including non-idempotent writes, is the one thing this change must not do on a payments path. Two changes and this is good to merge: