{"slug": "resilient-llm-calls-in-typescript-retries-breakers-fallbacks", "title": "Resilient LLM Calls in TypeScript: Retries, Breakers, Fallbacks", "summary": "Priya Nair published a tutorial showing how to wrap Anthropic's Claude API calls in TypeScript with Cockatiel retries and a circuit breaker that fails over from Claude Opus 5 to Claude Sonnet 5, using about 70 lines of code. The implementation uses Cockatiel 4.0.0 and @anthropic-ai/sdk 0.122.0, with retry policy set to 3 attempts after the first call, exponential backoff starting at 500ms capped at 8s, and a circuit breaker that opens after 5 consecutive transient failures and allows a probe after 30 seconds.", "body_md": "# Resilient LLM Calls in TypeScript: Retries, Breakers, Fallbacks\n\nWrap Claude API calls with Cockatiel retries and a circuit breaker that fails over to a second model.\n\n[Priya Nair](https://sourcefeed.dev/u/priya_nair)\n\n## 1. What you'll build\n\nAn `ask(prompt)`\n\nfunction that calls Claude Opus 5 with exponential-backoff retries, trips a circuit breaker after repeated failures, and answers from Claude Sonnet 5 whenever the primary model is overloaded, rate-limited, unreachable, or gone. About 70 lines, built on [Cockatiel](https://github.com/connor4312/cockatiel) and the [Anthropic TypeScript SDK](https://platform.claude.com/docs/en/cli-sdks-libraries/sdks/typescript).\n\n## 2. Prerequisites\n\n[Node.js](https://nodejs.org/)24 LTS (verified on 24.20.0). Node runs`.ts`\n\nfiles directly since 22.18, so there's no build step. Cockatiel 4 requires Node 22 or newer.`@anthropic-ai/sdk`\n\n0.122.0 and`cockatiel`\n\n4.0.0, the current releases as of this writing.`typescript`\n\n7.0.2 and`@types/node`\n\n, used only for`tsc --noEmit`\n\n; Node strips the types at runtime.- An Anthropic API key exported as\n`ANTHROPIC_API_KEY`\n\n, with access to`claude-opus-5`\n\nand`claude-sonnet-5`\n\n. - Commands are for macOS/Linux. On Windows, set the environment variables with\n`$env:NAME = \"value\"`\n\nin PowerShell.\n\n## 3. Set up the project\n\n```\nmkdir resilient-llm && cd resilient-llm\nnpm init -y\nnpm pkg set type=module\nnpm install @anthropic-ai/sdk cockatiel\nnpm install -D typescript @types/node\n```\n\n`type: module`\n\nmatters: Cockatiel 4 ships ESM only. Add a minimal `tsconfig.json`\n\nso your editor and `tsc`\n\nunderstand `.ts`\n\nimports:\n\n```\n{\n  \"compilerOptions\": {\n    \"target\": \"es2022\",\n    \"module\": \"nodenext\",\n    \"strict\": true,\n    \"noEmit\": true,\n    \"allowImportingTsExtensions\": true,\n    \"skipLibCheck\": true\n  },\n  \"include\": [\"src\"]\n}\n```\n\n## 4. Define the retry and circuit-breaker policies\n\nCreate `src/resilient.ts`\n\n. The first job is deciding which errors count as transient. The SDK throws typed subclasses of `Anthropic.APIError`\n\nwith a `status`\n\nfield, which makes this a one-liner:\n\n``` python\nimport Anthropic from \"@anthropic-ai/sdk\";\nimport {\n  BrokenCircuitError,\n  ConsecutiveBreaker,\n  ExponentialBackoff,\n  circuitBreaker,\n  fallback,\n  handleWhen,\n  retry,\n  wrap,\n} from \"cockatiel\";\n\nexport const PRIMARY_MODEL = process.env.PRIMARY_MODEL ?? \"claude-opus-5\";\nexport const FALLBACK_MODEL = process.env.FALLBACK_MODEL ?? \"claude-sonnet-5\";\n\n// maxRetries: 0 hands retries to Cockatiel. The SDK default of 2 would\n// multiply every Cockatiel attempt by three.\nconst client = new Anthropic({ maxRetries: 0, timeout: 60_000 });\n\n// Worth retrying: network errors, 408/409/429, and any 5xx (529 = overloaded).\n// 400/401/403/404 are our mistake or a dead model; retrying just burns time.\nconst isTransient = (err: unknown): boolean =>\n  err instanceof Anthropic.APIConnectionError ||\n  (err instanceof Anthropic.APIError &&\n    (err.status === 408 ||\n      err.status === 409 ||\n      err.status === 429 ||\n      (err.status ?? 0) >= 500));\n\n// 3 retries after the first call, starting around 500ms and capped at 8s, with jitter.\nexport const retryPolicy = retry(handleWhen(isTransient), {\n  maxAttempts: 3,\n  backoff: new ExponentialBackoff({ initialDelay: 500, maxDelay: 8_000 }),\n});\n\n// Open after 5 consecutive transient failures; let one probe through after 30s.\nexport const breaker = circuitBreaker(handleWhen(isTransient), {\n  halfOpenAfter: 30_000,\n  breaker: new ConsecutiveBreaker(5),\n});\n\n// Retry outside, breaker inside. An open circuit throws BrokenCircuitError,\n// which isTransient doesn't match, so the retry loop stops immediately.\nconst primaryPolicy = wrap(retryPolicy, breaker);\n\nretryPolicy.onRetry(({ attempt, delay }) =>\n  console.warn(`[retry] ${PRIMARY_MODEL} failed; retry ${attempt}/3 in ${Math.round(delay)}ms`),\n);\nbreaker.onBreak(() => console.warn(`[breaker] open: fast-failing ${PRIMARY_MODEL} for 30s`));\nbreaker.onHalfOpen(() => console.warn(`[breaker] half-open: probing ${PRIMARY_MODEL}`));\nbreaker.onReset(() => console.warn(`[breaker] closed: ${PRIMARY_MODEL} healthy again`));\n```\n\nTwo details: `maxAttempts`\n\ncounts retries after the initial call, so this policy makes up to four requests. And because the breaker sits inside the retry, every failed attempt feeds `ConsecutiveBreaker`\n\n; one fully failed request is four of the five strikes.\n\n## 5. Add the model fallback\n\nAppend this to `src/resilient.ts`\n\n. The fallback policy is built per call because its factory can't see the wrapped function's arguments and needs the prompt. It holds no state, so that's free:\n\n```\n// Fall back when the primary is unhealthy or gone, not when the request itself is bad.\nconst shouldFallBack = (err: unknown): boolean =>\n  isTransient(err) ||\n  err instanceof BrokenCircuitError ||\n  err instanceof Anthropic.NotFoundError;\n\nexport interface Answer {\n  model: string;\n  text: string;\n}\n\nasync function callModel(model: string, prompt: string): Promise<Answer> {\n  const message = await client.messages.create({\n    model,\n    max_tokens: 1024,\n    messages: [{ role: \"user\", content: prompt }],\n  });\n  const text = message.content\n    .filter((block) => block.type === \"text\")\n    .map((block) => block.text)\n    .join(\"\");\n  return { model: message.model, text };\n}\n\nexport async function ask(prompt: string): Promise<Answer> {\n  const withFallback = fallback(handleWhen(shouldFallBack), () => {\n    console.warn(`[fallback] ${PRIMARY_MODEL} unavailable; answering with ${FALLBACK_MODEL}`);\n    return retryPolicy.execute(() => callModel(FALLBACK_MODEL, prompt));\n  });\n\n  return await withFallback.execute(() =>\n    primaryPolicy.execute(() => callModel(PRIMARY_MODEL, prompt)),\n  );\n}\n```\n\n`NotFoundError`\n\nis on the fallback list on purpose: a retired or mistyped model ID returns 404, and that's exactly when traffic should move. `BadRequestError`\n\n(400) is not on the list; a malformed request fails on the secondary too, so let it throw.\n\nThe fallback call reuses `retryPolicy`\n\nbut skips the breaker. The breaker tracks the primary's health, and sharing it would let a sick primary block a healthy secondary.\n\nNow `src/index.ts`\n\n. Node's type stripping requires the `.ts`\n\nextension on relative imports:\n\n``` js\nimport { ask } from \"./resilient.ts\";\n\nconst { model, text } = await ask(\n  \"In one sentence, what does a circuit breaker do in a distributed system?\",\n);\nconsole.log(`[${model}] ${text}`);\n```\n\n## 6. Verify it works\n\nType-check, then run:\n\n```\nnpx tsc\nnode src/index.ts\n```\n\n`tsc`\n\nprints nothing on success. On a healthy day the primary answers and no policy logs fire:\n\n```\n[claude-opus-5] A circuit breaker stops sending requests to a failing service so the failure can't cascade, then periodically tests whether it has recovered.\n```\n\nNow force the fallback path by pointing the primary at a model that doesn't exist. The API returns a 404, which skips retries and goes straight to Sonnet:\n\n``` bash\n$ PRIMARY_MODEL=claude-nope-1 node src/index.ts\n[fallback] claude-nope-1 unavailable; answering with claude-sonnet-5\n[claude-sonnet-5] A circuit breaker detects repeated failures from a dependency and temporarily blocks calls to it, giving it time to recover instead of cascading the failure.\n```\n\nDuring a real 529 storm you'll see `[retry] ... retry 1/3 in 612ms`\n\nlines before either the primary recovers or `[fallback]`\n\nfires. If several requests fail back to back, `[breaker] open`\n\nappears and later requests skip the primary for 30 seconds.\n\nNode 24.1 through 24.19 print `ExperimentalWarning: Type Stripping is an experimental feature`\n\non startup. It's harmless; 24.20 dropped it.\n\n## 7. Troubleshooting\n\n** TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension \".ts\"**\nYou're on Node 20 or an early 22.x. Type stripping shipped by default in 22.18 and 23.6. Upgrade to 24 LTS, or run the file with\n\n`npx tsx src/index.ts`\n\non older versions.** Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../src/resilient' imported from .../src/index.ts**\nThe import is missing its extension. Node doesn't resolve\n\n`./resilient`\n\nto `./resilient.ts`\n\n; write the `.ts`\n\nexplicitly. The `allowImportingTsExtensions`\n\nflag in `tsconfig.json`\n\nkeeps `tsc`\n\nfrom objecting.`Error: Could not resolve authentication method. Expected one of apiKey, authToken, credentials, config, or profile to be set.`\n\n`ANTHROPIC_API_KEY`\n\nisn't visible to the process. Run `export ANTHROPIC_API_KEY=sk-ant-...`\n\nin the same shell, or prefix the command with it. The client reads the variable at construction time, so a key set after import won't be picked up.\n\n** BadRequestError: 400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\", ...}}**\nThe request itself is invalid, so neither the retry nor the fallback policy handles it, by design. Read the\n\n`message`\n\nfield; it names the offending parameter (for example a `max_tokens`\n\nabove the model's limit). Fix the request rather than widening `shouldFallBack`\n\n.## 8. Next steps\n\nHonor `retry-after`\n\non 429s. The SDK's built-in retries do this and Cockatiel's don't; swap `ExponentialBackoff`\n\nfor a `DelegateBackoff`\n\nthat reads the header off the failed error in the retry context. For production, replace `ConsecutiveBreaker`\n\nwith `SamplingBreaker`\n\nso a 20% failure rate over 30 seconds opens the circuit instead of five unlucky requests in a row, and wire `onBreak`\n\n/`onReset`\n\ninto your metrics. Cross-provider fallback is the same shape: point the fallback factory at a different SDK and keep the policies unchanged.\n\nThe Claude API also has a server-side fallback for a different failure: a request the model declines rather than an HTTP error. See [Refusals and fallback](https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback) for the `fallbacks: \"default\"`\n\nbeta.\n\n## Sources & further reading\n\n-\n[Cockatiel README (retry, circuitBreaker, fallback, wrap APIs)](https://github.com/connor4312/cockatiel)— github.com -\n[Anthropic TypeScript SDK: errors, retries, timeouts](https://platform.claude.com/docs/en/cli-sdks-libraries/sdks/typescript)— platform.claude.com -\n[Claude API errors](https://platform.claude.com/docs/en/api/errors)— platform.claude.com -\n[Claude models overview (model IDs)](https://platform.claude.com/docs/en/models/overview)— platform.claude.com -\n[Node.js: Modules: TypeScript (type stripping)](https://nodejs.org/api/typescript.html)— nodejs.org -\n[Refusals and fallback](https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback)— platform.claude.com\n\n[Priya Nair](https://sourcefeed.dev/u/priya_nair)· AI & Developer Experience Writer\n\nPriya covers AI frameworks, developer productivity tooling, and the startup ecosystem across South and Southeast Asia, bringing a researcher's rigour and a practitioner's empathy to every story. She is deeply sceptical of benchmarks and asks hard questions so her readers don't have to.\n\n## Discussion 0\n\nNo comments yet\n\nBe the first to weigh in.", "url": "https://wpnews.pro/news/resilient-llm-calls-in-typescript-retries-breakers-fallbacks", "canonical_source": "https://sourcefeed.dev/a/resilient-llm-calls-in-typescript-retries-breakers-fallbacks", "published_at": "2026-08-28 17:43:21+00:00", "updated_at": "2026-08-28 17:49:30.374007+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "artificial-intelligence"], "entities": ["Priya Nair", "Anthropic", "Claude Opus 5", "Claude Sonnet 5", "Cockatiel", "TypeScript", "Node.js"], "alternates": {"html": "https://wpnews.pro/news/resilient-llm-calls-in-typescript-retries-breakers-fallbacks", "markdown": "https://wpnews.pro/news/resilient-llm-calls-in-typescript-retries-breakers-fallbacks.md", "text": "https://wpnews.pro/news/resilient-llm-calls-in-typescript-retries-breakers-fallbacks.txt", "jsonld": "https://wpnews.pro/news/resilient-llm-calls-in-typescript-retries-breakers-fallbacks.jsonld"}}