Files
OmniRoute/open-sse/services/transientBackendRetry.ts
Koosha Paridehpour 2dae6df518 feat(sse): retry transient 5xx backend errors with jitter (global-fallback call site) (#12695) (#13143)
* feat(sse): retry transient 5xx backend errors with jitter (global-fallback call site) (#12695)

Per Diego's review feedback on the original PR (which was closed for base drift + over-broad scope):

- Helper in tests/unit/ (not open-sse/__tests__) - 7 node:test cases
- Drop 429 from retryable set (keep 502/503/504) - 429 must respect the
  RateLimit-Reset header and be surfaced, not silently retried
- Touch ONLY the global-fallback call site in chat.ts - the
  combo-target loop has its own retry semantics already

The wrap uses decorrelated full-jitter exponential backoff (per AWS
retry guidance), abort-aware (client disconnect cancels immediately),
and respects the AbortSignal via a sleep that throws on abort.

Re-cherry-pick: dropped all unrelated drift from the original PR
(videoBridgeLog plumbing, forcedConnectionId refactor,
comboCheckProvider/ghComboGate, getPassthroughProviders, Moonshot
quota fetcher, withSelectedConnectionHeader, classifyProviderBreakerResult,
reanchorVideoBridgeRedaction, etc.). chat.ts is now +20/-16 — purely the
import + the wrap around handleSingleModelChat in the global-fallback
block. Diego should now see a clean, focused diff.

Fixes #12695

* fix(sse): address kilo-code-bot review on transientBackendRetry helper

Per the review comment on the previous commit:
- Jitter formula now matches documented AWS decorrelated jitter
  (temp = min(cap, random(base, prev*3))) instead of an ad-hoc
  baseMs + rand*prev*2 approximation
- AbortSignal is honoured by the default sleep implementation (was
  previously only honoured by the loop's pre-attempt check)
- 'source' option added to TransientRetryOptions so callers can
  propagate an observability label (e.g. 'global-fallback') through
  onRetry

Adds 3 tests:
- default sleep respects AbortSignal without custom sleep
- onRetry receives source label
- decorrelated jitter is bounded by capMs

10/10 unit tests pass.

---------

Co-authored-by: KooshaPari <kooshapari@users.noreply.github.com>
2026-09-17 10:43:54 -03:00

120 lines
4.2 KiB
TypeScript

/**
* transientBackendRetry — bounded retry-with-jitter for transient HTTP errors.
*
* Only retryable: 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout.
* 429 is intentionally NOT retryable here — the upstream proxy uses 429 as a
* per-tenant policy signal (quota, region, credential) and the same retry would
* just hit the same policy.
*
* Strategy: decorrelated full-jitter (AWS pattern). Each attempt picks a random
* delay in [baseMs, prev*3], capped at capMs. With defaults (baseMs=200, capMs=2000)
* the worst-case wall-clock for 3 attempts is ~7s.
*/
export const TRANSIENT_BACKEND_STATUS_CODES: ReadonlySet<number> = new Set([
502,
503,
504,
]);
export function isResponseStatusRetryable(status: number | undefined | null): boolean {
if (typeof status !== "number") return false;
return TRANSIENT_BACKEND_STATUS_CODES.has(status);
}
export interface TransientRetryOptions {
maxAttempts?: number;
baseMs?: number;
capMs?: number;
/** Source label propagated to onRetry for observability (e.g. "global-fallback"). */
source?: string;
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
signal?: AbortSignal;
onRetry?: (info: { attempt: number; delayMs: number; status?: number; error?: unknown; source?: string }) => void;
}
export interface ResponseLike {
ok: boolean;
status: number;
body: unknown;
}
const DEFAULT_SLEEP = (ms: number, signal?: AbortSignal): Promise<void> =>
new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
return;
}
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = (): void => {
clearTimeout(timer);
reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
};
signal?.addEventListener("abort", onAbort, { once: true });
});
/**
* Run an action that returns a ResponseLike or throws. Retries while the response
* is non-OK with a transient status (502/503/504). Honours AbortSignal between
* attempts. Returns the last response on exhaustion — never throws on a transient
* HTTP response (only throws if the action itself throws).
*/
export async function runWithTransientBackendRetry<T extends ResponseLike>(
action: () => Promise<T>,
options: TransientRetryOptions = {},
): Promise<T> {
const maxAttempts = options.maxAttempts ?? 3;
const baseMs = options.baseMs ?? 200;
const capMs = options.capMs ?? 2000;
const sleep = options.sleep ?? DEFAULT_SLEEP;
const signal = options.signal;
const onRetry = options.onRetry;
const source = options.source;
let prev = baseMs;
let lastResult: T | undefined;
// Decorrelated jitter (AWS pattern): temp = min(cap, random(base, prev*3)); prev = temp.
// See https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ — "full jitter"
// variant of decorrelated jitter produces lower contention under thundering-herd conditions
// than naive exponential backoff with constant jitter.
const decorrelatedDelay = (): number => {
const upper = Math.max(baseMs, prev * 3);
const candidate = baseMs + Math.floor(Math.random() * (upper - baseMs + 1));
return Math.min(capMs, candidate);
};
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (signal?.aborted) {
throw new DOMException("Retry aborted", "AbortError");
}
try {
const result = await action();
lastResult = result;
if (result.ok) return result;
if (!isResponseStatusRetryable(result.status)) return result;
// Exhausted: return the last result rather than throwing
if (attempt >= maxAttempts) return result;
const delayMs = decorrelatedDelay();
onRetry?.({ attempt, delayMs, status: result.status, source });
await sleep(delayMs, signal);
prev = delayMs;
} catch (error) {
if (signal?.aborted) {
throw error;
}
if (attempt >= maxAttempts) throw error;
const delayMs = decorrelatedDelay();
onRetry?.({ attempt, delayMs, error, source });
await sleep(delayMs, signal);
prev = delayMs;
}
}
// Unreachable, but TypeScript wants an explicit return
return lastResult as T;
}