@khalidsaidi/fallback-chain-js
v0.2.0
Published
Tiny fallback chains for JS/TS — try providers until one succeeds.
Maintainers
Readme
fallback-chain-js
Tiny fallback chains for JS/TS — try providers until one succeeds.
- Tiny core, zero runtime deps
- Works in Node, Bun, and Cloudflare Workers
- Fallback on errors AND unacceptable results
- AbortSignal + per-attempt timeouts
- Optional stateful chains with per-candidate cooldown/health memory (
createFallbackChain) - Streaming fallback with commit-on-first-chunk semantics (
fallbackStream) - Great for HTTP, storage, and LLM/provider failover
Demo app: https://fallbacklab.vercel.app
Quickstart
import { fallback } from "@khalidsaidi/fallback-chain-js";
const result = await fallback([
() => fetch("https://primary.example.com").then((r) => r.text()), // this host is down → falls back
() => "hello from the backup provider"
]);
console.log(result); // "hello from the backup provider"Why not X?
| Library | Difference |
|---------|-----------|
| Promise.any | Runs all promises immediately; this lib runs candidates lazily |
| p-retry | Retries the same operation; this lib tries different providers |
| cockatiel | Full resilience suite (circuit breakers, bulkheads); this lib is a focused primitive |
| async-retry | Same-operation retry with backoff; no multi-provider support |
| ai-fallback / Vercel AI Gateway | Provider failover built on/into the Vercel AI SDK. If you're all-in on the Vercel AI SDK, use those — they're deeply integrated. Use this when you're not: plain fetch, provider SDKs, non-LLM work, or when you want failover without adopting a framework |
This library is a primitive — single purpose, predictable, zero deps. Compose it with other tools as needed.
Install
npm i @khalidsaidi/fallback-chain-jsUsage
Basic: first success wins
const value = await fallback([
() => primary(),
() => secondary()
]);Fallback on "bad results"
import { fallback, acceptOk } from "@khalidsaidi/fallback-chain-js";
const response = await fallback(
[() => fetch(urlA), () => fetch(urlB)],
{ accept: acceptOk }
);Timeouts + AbortSignal
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
const value = await fallback([
({ signal }) => fetch(urlA, { signal }).then((r) => r.json()),
({ signal }) => fetch(urlB, { signal }).then((r) => r.json())
], {
signal: controller.signal,
timeoutMs: 1_000
});An invalid timeoutMs (negative, NaN, or not a number) throws a TypeError
immediately — it never silently disables the timeout.
Abort-listener footgun to know about: an AbortSignal that is already
aborted never fires its "abort" event, so the pattern
signal.addEventListener("abort", handler, { once: true }) silently does
nothing if the signal aborted before you attached the listener. Always check
signal.aborted first, then attach with { once: true }. For the signal
you pass into fallback()/fallbackStream(), the library already handles
both cases for you.
Accept Helpers
Built-in validators for common patterns:
import {
fallback,
acceptOk, // res.ok === true
acceptStatus, // res.status in [200, 201, ...]
acceptTruthy, // Boolean(value) === true
acceptDefined // value !== null && value !== undefined
} from "@khalidsaidi/fallback-chain-js";
// HTTP responses: fall back past the 500
const res = await fallback([
() => ({ ok: false, status: 500 }), // stand-in for fetch(primaryUrl)
() => ({ ok: true, status: 200 }) // stand-in for fetch(backupUrl)
], { accept: acceptOk });
console.log(res.status); // 200
// ...or accept only specific status codes
const created = await fallback([
() => ({ ok: false, status: 500 }),
() => ({ ok: true, status: 201 })
], { accept: acceptStatus(200, 201, 204) });
console.log(created.status); // 201
// General values: skip empty/undefined results
const text = await fallback([() => "", () => "real content"], { accept: acceptTruthy });
console.log(text); // "real content"
const cached = await fallback([() => undefined, () => 0], { accept: acceptDefined });
console.log(cached); // 0 (defined, even though falsy)Real-World Examples
LLM Provider Failover
Different providers return different response shapes, so each candidate
normalizes to one common type. That lets a single accept (and your caller)
work with every provider:
interface LLMResult {
provider: string;
text: string;
}
const result = await fallback<LLMResult>([
{
name: "openai",
run: async () => {
const res = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }]
});
return { provider: "openai", text: res.choices[0]?.message?.content ?? "" };
}
},
{
name: "anthropic",
run: async () => {
const res = await anthropic.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 1024,
messages: [{ role: "user", content: prompt }]
});
const block = res.content[0];
return { provider: "anthropic", text: block?.type === "text" ? block.text : "" };
}
}
], {
accept: (r) => r.text.length > 0, // one accept, one shape
timeoutMs: 30_000,
onAttempt: ({ name, outcome }) => console.log(`${name}: ${outcome}`)
});
console.log(`${result.provider} said: ${result.text}`);Multi-Region Storage
import { fallback, acceptDefined } from "@khalidsaidi/fallback-chain-js";
// Stand-ins — swap for your real clients (AWS SDK S3, Cloudflare R2 bindings, ...)
const s3UsEast = { getObject: async (key: string): Promise<string | undefined> => { throw new Error("region down"); } };
const s3EuWest = { getObject: async (key: string): Promise<string | undefined> => `object:${key}` };
const r2 = { get: async (key: string): Promise<string | undefined> => `object:${key}` };
const key = "report.pdf";
const data = await fallback([
() => s3UsEast.getObject(key),
() => s3EuWest.getObject(key),
() => r2.get(key)
], { accept: acceptDefined });
console.log(data); // "object:report.pdf" — served by eu-west after us-east failedCache-Through Pattern
import { fallback, acceptDefined } from "@khalidsaidi/fallback-chain-js";
// Stand-ins — swap for your real clients (ioredis, pg, an internal service API, ...)
const redis = { get: async (k: string) => null }; // cache miss
const postgres = { query: async (sql: string, params: unknown[]) => ({ id: params[0], name: "Ada" }) };
const userServiceApi = { getUser: async (id: string) => ({ id, name: "Ada" }) };
const id = "42";
const user = await fallback([
() => redis.get(`user:${id}`),
() => postgres.query("SELECT * FROM users WHERE id = $1", [id]),
() => userServiceApi.getUser(id)
], { accept: acceptDefined });
console.log(user); // { id: "42", name: "Ada" } — from Postgres after the cache missStateful Chains: Cooldown + Health (createFallbackChain)
fallback() is stateless — every call starts at candidate #1. When a provider
is having a bad hour, you don't want to pay its timeout on every single call.
createFallbackChain remembers recent failures per candidate:
- A candidate that fails goes on cooldown (default 30s, configurable).
- Cooling-down candidates are deprioritized to the end of the chain — not skipped outright, so the chain can never fail purely because everything is cooling down.
- A success resets that candidate's failure memory.
failureThreshold(default 1) sets how many consecutive failures trigger the cooldown.
It's an optional, tree-shakeable wrapper — the plain fallback() is untouched.
import { createFallbackChain } from "@khalidsaidi/fallback-chain-js";
const chain = createFallbackChain([
{ name: "primary", run: () => callPrimary() },
{ name: "backup", run: () => callBackup() }
], {
cooldownMs: 30_000, // deprioritize a failing candidate for 30s
failureThreshold: 1, // ...after a single failure
timeoutMs: 5_000 // all fallback() options work here too
});
// Call it per request. While "primary" is cooling down, "backup" is tried first.
const a = await chain.run();
const b = await chain.run({ signal: requestSignal }); // per-call overrides merge over base options
chain.health();
// [
// { index: 0, name: "primary", consecutiveFailures: 1, coolingDown: true, cooldownUntil: 1735000030000, lastError: Error },
// { index: 1, name: "backup", consecutiveFailures: 0, coolingDown: false, cooldownUntil: 0 }
// ]
chain.reset(); // clear all failure/cooldown memorySemantics worth knowing:
- Failures that count against health: rejections, timeouts, and
acceptvetoes. Caller-initiated aborts do not count — they say nothing about the candidate. - Concurrent
chain.run()calls are safe: each call snapshots an ordering up front, and every settled attempt applies one synchronous state update. - After a cooldown expires the candidate is tried in its original position again; if it fails once more it immediately re-enters cooldown.
- Post-cooldown revival is not serialized: each
run()snapshots the ordering up front, so N concurrent calls arriving just after a cooldown expires may each probe the revived candidate before the first probe settles. If a single canary probe matters (an expensive or fragile provider), serialize it yourself — e.g. wrapchain.run()in a small in-flight dedupe/mutex while a candidate is coming off cooldown.
Streaming Fallback (fallbackStream)
Falling back on a stream (an LLM token stream, an SSE feed) has a hard
constraint that request/response fallback doesn't: once the consumer has
seen chunks from provider A, you cannot splice provider B onto them — B
would restart from the beginning and the consumer would render a garbled
mixture. Mid-stream fallback is unsolvable UX, so fallbackStream doesn't
attempt it. The semantics are deliberately crisp:
- Before the first chunk reaches the consumer, anything goes wrong —
error, timeout,
acceptFirstChunkveto — and we fall back to the next candidate. Nothing has been shown, so falling back is invisible. - From the first chunk on, we are committed: errors propagate as-is.
- Abandoned candidates are cleaned up: their iterator's
return()is called and late-arriving chunks are dropped, never yielded to the consumer. - Consumer cleanup propagates:
break/return()/throw()on the outer stream reaches the inner iterator (your provider'sfinallyruns). - A stream that completes cleanly with zero chunks completes the output stream — clean completion is not an error.
import { fallbackStream } from "@khalidsaidi/fallback-chain-js";
const stream = fallbackStream([
{ name: "openai", run: ({ signal }) => openaiTokenStream(prompt, signal) },
{ name: "anthropic", run: ({ signal }) => anthropicTokenStream(prompt, signal) }
], {
timeoutMs: 3_000, // time budget to the FIRST chunk, per attempt
acceptFirstChunk: (chunk) => chunk.length > 0, // veto before committing
retryable: (err) => !isAuthError(err), // don't rotate providers on bad credentials
onAttempt: ({ name, outcome }) => console.log(`${name}: ${outcome}`)
});
for await (const token of stream) {
process.stdout.write(token);
}Candidates can return any AsyncIterable, AsyncIterator, or sync
Iterable — async generators, ReadableStreams (they're async-iterable in
Node/Workers/Bun), or arrays.
timeoutMs measures time to first chunk only. Once committed, a slow
chunk mid-stream is between the consumer and the provider (use ctx.signal
or an outer AbortSignal for whole-stream deadlines).
Recipes
This library is a primitive. Here's how to compose it for advanced patterns:
Retryable LLM Errors
Fall back on errors that a different provider might fix (429 rate limits, 5xx, timeouts, overloaded); throw fast on errors that every provider will reject the same way (bad request, bad API key, content policy):
import { fallback, TimeoutError } from "@khalidsaidi/fallback-chain-js";
function retryableLLM(err: unknown): boolean {
if (err instanceof TimeoutError) return true; // provider too slow → try next
const status = (err as any)?.status ?? (err as any)?.response?.status;
if (status === 429) return true; // rate limited → try next
if (typeof status === "number" && status >= 500) return true; // provider down → try next
if ((err as any)?.error?.type === "overloaded_error") return true; // Anthropic 529-style
return false; // 400/401/403/content-policy: every provider agrees — throw fast
}
const result = await fallback([
{ name: "openai", run: () => askOpenAI(prompt) },
{ name: "anthropic", run: () => askAnthropic(prompt) }
], {
retryable: retryableLLM,
timeoutMs: 30_000
});Retry Within a Provider, Fall Back Across Providers
p-retry retries the same operation; this lib rotates different
providers. They compose cleanly — give each provider a few attempts with
backoff before rotating:
import pRetry from "p-retry";
import { fallback } from "@khalidsaidi/fallback-chain-js";
const withRetries = (fn: () => Promise<string>) =>
pRetry(fn, { retries: 2, minTimeout: 250 }); // 3 total attempts, exponential backoff
const result = await fallback([
{ name: "openai", run: () => withRetries(() => askOpenAI(prompt)) },
{ name: "anthropic", run: () => withRetries(() => askAnthropic(prompt)) }
]);
// Worst case: openai tried 3×, then anthropic tried 3×.Hedged Requests
Start a backup request if the primary is slow (Google's "Tail at Scale" pattern):
async function hedge<T>(
primary: () => Promise<T>,
backup: () => Promise<T>,
hedgeAfterMs: number
): Promise<T> {
const controller = new AbortController();
let backupStarted = false;
const withBackup = new Promise<T>((resolve) => {
setTimeout(() => {
if (!controller.signal.aborted) {
backupStarted = true;
backup().then(resolve);
}
}, hedgeAfterMs);
});
const result = await Promise.race([
primary().then((v) => { controller.abort(); return v; }),
withBackup
]);
return result;
}
// Usage
const data = await hedge(
() => fetchPrimary(),
() => fetchBackup(),
100 // start backup if primary takes >100ms
);Parallel Race with Accept
Run all candidates in parallel, first acceptable result wins:
async function race<T>(
candidates: Array<() => Promise<T>>,
accept: (v: T) => boolean = () => true
): Promise<T> {
const controller = new AbortController();
return Promise.any(
candidates.map(async (fn) => {
const value = await fn();
if (!accept(value)) throw new Error("unacceptable");
controller.abort();
return value;
})
);
}Get Winner Metadata
Track which candidate succeeded using the existing onAttempt hook.
info.attempt is 0-based: 0 means the first candidate won, 1 the
second, and so on.
import { fallback } from "@khalidsaidi/fallback-chain-js";
let winner: { name?: string; attempt: number; durationMs: number } | undefined;
const value = await fallback([
{ name: "primary", run: () => fetchPrimary() },
{ name: "backup", run: () => fetchBackup() }
], {
onAttempt: (info) => {
if (info.outcome === "success") {
// info.attempt: 0 = primary, 1 = backup
winner = { name: info.name, attempt: info.attempt, durationMs: info.durationMs };
}
}
});
console.log(`Winner: ${winner?.name} (attempt #${winner?.attempt})`);API
fallback<T>(
candidates: readonly Candidate<T>[],
options?: FallbackOptions<T>
): Promise<T>Candidates:
() => T | Promise<T>{ name?: string, run: (ctx) => T | Promise<T> }
Options:
signal?: AbortSignaltimeoutMs?: number | (ctx) => number | undefined— invalid values (negative,NaN, non-number) throw aTypeErroraccept?: (value, { attempt }) => booleanretryable?: (error, { attempt }) => booleanonAttempt?: ({ attempt, name, outcome, durationMs, value?, error? }) => void
attempt is 0-based everywhere it appears — in accept, retryable,
onAttempt, and the ctx passed to candidates and to the timeoutMs
function. The first candidate runs as attempt: 0.
Errors:
TimeoutError— candidate exceededtimeoutMsFallbackError— all candidates failed..errorsis the per-attempt error array (in attempt order)..namedis the same list as[{ name?, error }], carrying each candidate's name when it was given as{ name, run }— and named candidates show up in the message too, e.g.All 2 fallback candidates failed (primary: Error: boom; backup: TimeoutError: Timed out after 1000ms)
createFallbackChain<T>(
candidates: readonly Candidate<T>[],
options?: FallbackChainOptions<T>
): FallbackChain<T>Options: everything fallback() takes, plus:
cooldownMs?: number— deprioritization window after failures (default30_000)failureThreshold?: number— consecutive failures before cooldown (default1)now?: () => number— injectable clock for tests
Returns:
run(overrides?)— run the chain once; healthy candidates first, cooling ones lasthealth()— per-candidate{ index, name?, consecutiveFailures, coolingDown, cooldownUntil, lastError? }reset()— clear all failure/cooldown memory
fallbackStream<T>(
candidates: readonly StreamCandidate<T>[],
options?: FallbackStreamOptions<T>
): AsyncGenerator<T>Candidates: (ctx) => AsyncIterable<T> | AsyncIterator<T> | Iterable<T> (or { name?, run })
Options:
signal?: AbortSignaltimeoutMs?— per-attempt budget to the first chunkretryable?: (error, { attempt }) => boolean— applies to pre-first-chunk errorsacceptFirstChunk?: (chunk, { attempt }) => boolean— veto a candidate before committingonAttempt?— same shape asfallback();"success"means committed (or clean empty completion)
As with fallback(), attempt is 0-based in retryable,
acceptFirstChunk, onAttempt, and the candidate ctx.
Semantics: fallback happens only before the first chunk reaches the consumer; after that, errors propagate (mid-stream fallback is unsolvable UX).
Runtime Support
Node 18+ / Bun / Cloudflare Workers (tested in CI)
Contributing
pnpm install
pnpm -C packages/fallback-chain-js build
pnpm -C packages/fallback-chain-js testLicense
MIT
