@khalidsaidi/fallback-chain-js
v0.1.1
Published
Tiny fallback chains for JS/TS — try providers until one succeeds.
Downloads
18
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
- 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()),
() => fetch("https://backup.example.com").then((r) => r.text())
]);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 |
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
});Accept Helpers
Built-in validators for common patterns:
import {
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
await fallback([...], { accept: acceptOk });
await fallback([...], { accept: acceptStatus(200, 201, 204) });
// General values
await fallback([...], { accept: acceptTruthy });
await fallback([...], { accept: acceptDefined });Real-World Examples
LLM Provider Failover
const response = await fallback([
{ name: "openai", run: () => openai.chat.completions.create({...}) },
{ name: "anthropic", run: () => anthropic.messages.create({...}) },
{ name: "local", run: () => ollama.chat({...}) }
], {
accept: (r) => r.choices?.[0]?.message?.content?.length > 0,
timeoutMs: 30_000,
onAttempt: ({ name, outcome }) => console.log(`${name}: ${outcome}`)
});Multi-Region Storage
const data = await fallback([
() => s3UsEast.getObject(key),
() => s3EuWest.getObject(key),
() => r2.get(key)
], { accept: acceptDefined });Cache-Through Pattern
const user = await fallback([
() => redis.get(`user:${id}`),
() => postgres.query("SELECT * FROM users WHERE id = $1", [id]),
() => userServiceApi.getUser(id)
], { accept: acceptDefined });Recipes
This library is a primitive. Here's how to compose it for advanced patterns:
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:
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") {
winner = { name: info.name, attempt: info.attempt, durationMs: info.durationMs };
}
}
});
console.log(`Winner: ${winner?.name}`);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 | undefinedaccept?: (value, { attempt }) => booleanretryable?: (error, { attempt }) => booleanonAttempt?: ({ attempt, name, outcome, durationMs, value?, error? }) => void
Errors:
TimeoutError— candidate exceededtimeoutMsFallbackError— all candidates failed (includes.errorsarray)
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
