Skip to main content
Crashbytes logoCrashbytes
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Browse Articles
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Network
Theme
Browse Articles
Crashbytes logoCrashbytes

Expert insights on web development, technology trends, and programming best practices. Learn from real-world experiences and cutting-edge techniques that help you build better software.

Follow Us

Our Sites

  • ๐Ÿ”ฎ Predictions
  • ๐Ÿ“ฐ Breaking News
  • ๐ŸŽจ AI Art
  • ๐Ÿ“– Short Stories
  • View All โ†’
  • Products โ†’

Sitemap

  • Home
  • All Articles
  • Open Source
  • Services
  • About Us
  • Contact
  • Donate Compute

Popular Topics

  • Serverless
  • Cloud Architecture
  • DevOps
  • Kubernetes
  • Platform Engineering

Resources

  • Privacy Policy
  • Terms of Service
  • Sitemap
  • RSS Feed
  • PGP Key

Stay Updated

Get the latest articles, tutorials, and insights delivered to your inbox. Join our community of developers and never miss an update.

ยฉ 2021-2026 Crashbytesยฎ by Blackhole Software, LLC. All rights reserved.
| Reg. U.S. Pat. & Tm. Off.

Made for the developer community

  1. Home
  2. /
  3. Articles
  4. /
  5. Build a Resilient Multi-Provider LLM Client in TypeScript: Timeouts, Retries, Circuit Breakers, and Failover
EngineeringJune 22, 202622 min readโ€ข By Michael Eakins

Build a Resilient Multi-Provider LLM Client in TypeScript: Timeouts, Retries, Circuit Breakers, and Failover

A hands-on TypeScript tutorial for building a production LLM client that survives slow upstreams, rate limits, transient 5xx errors, and whole-provider outages โ€” a per-attempt timeout, exponential backoff with jitter, a per-provider circuit breaker, and automatic failover, in about 250 lines of typed, fully tested code.

Quick Takeaways

What you'll learn in this article

22 min read
Intermediate
  • 1

    Plan for the provider you depend on going away โ€” the model-continuity case for treating provider risk as an engineering problem, which this client operationalizes.

  • 2

    Build an LLM-as-Judge Evaluation Harness in TypeScript โ€” the regression gate that makes swapping providers safe, and a sibling small, tested, provider-agnostic primitive.

  • 3

    Multi-provider resilience and the 2026 model fragmentation โ€” the market context for why provider diversity went mainstream this year.

  • 4

    Prediction: multi-provider failover becomes the default for enterprise LLM apps by the end of 2027 โ€” my dated, falsifiable claim on where this is heading.

  • 5

    Companion code on GitHub โ€” the full, tested project from this tutorial.

Keep reading for detailed implementation, code examples, and real-world results

Every team that ships an AI feature eventually has the same outage. The model provider returns a wall of 529s for ninety seconds during a traffic spike, or a region goes dark, or a rate limit you did not know you were near suddenly starts rejecting one request in three. The demo never showed this because the demo made one call at a time to a warm endpoint. Production makes thousands of calls against an upstream you do not control, and the difference between a feature that degrades gracefully and one that throws a 500 at every user is a few hundred lines of unglamorous client code that almost nobody writes until after the first incident.

This is the code that closes that gap, and in 2026 it stopped being optional. The reason is structural: the model market fragmented. A year ago a sensible default was to wire your app to one frontier provider and move on. Now there is a credible heavyweight reasoning model from a Chinese lab, a family of small efficient coding models from a hyperscaler explicitly built to reduce dependence on a single API, and the same handful of frontier models showing up behind three different cloud resellers with three different rate-limit pools. The upside is leverage and price competition. The cost is that "which model serves this request" is now a runtime decision your code has to be able to make โ€” and to re-make, instantly, when the first choice is failing.

What almost nobody tells you is that the machinery underneath a resilient client is not complicated. It is a deadline, a retry loop, a state machine that remembers which upstreams are sick, and a list you walk in order until something answers. You can buy this bundled into a proprietary gateway with a per-request markup and a dashboard, or you can own it in about two hundred and fifty lines of TypeScript that runs anywhere, talks to any provider, and is trivially testable without spending a cent on tokens. This tutorial builds the second thing.

By the end you will have a small, dependency-free client with four composable parts: a Provider interface that unifies every upstream behind one method, a withTimeout wrapper that enforces a hard per-attempt deadline, a withRetry function that does exponential backoff with full jitter, a CircuitBreaker that stops a dead provider from eating your latency budget, and a ResilientClient that wires them together and fails over across an ordered chain. Every piece is covered by deterministic tests, and the whole thing is provider-agnostic โ€” you plug in Anthropic, OpenAI, a local model, or a deterministic fake through a single one-line interface.

The companion code lives at CrashBytes/ByteSizedExamples/resilient-llm-client-typescript. Clone it, run npm install && npm test, and follow along.

Why one provider is a single point of failure

Before we write code, it is worth being precise about what we are defending against, because the failure modes shape every design decision that follows. A call to a hosted model can fail in at least five distinct ways, and they do not want the same response.

How a hosted-model call fails in production (illustrative distribution)

How a hosted-model call fails in production (illustrative distribution)
failureshare
Rate limit (429)34
Transient 5xx / overload28
Latency / timeout21
Bad request (4xx)10
Full provider outage7

A rate limit wants you to wait and try again, ideally after the delay the server told you to wait. A transient overload wants the same patience but without a hint. A timeout means the request is probably never coming back and you should cut it loose. A bad request โ€” a malformed payload, a revoked key, an unknown model โ€” will fail identically no matter how many times you retry, so retrying is pure waste. And a full provider outage means no amount of patience with that provider will help; the only recovery is a different provider entirely.

The naive client treats all five the same. It either gives up immediately, which turns a recoverable blip into a user-facing error, or it retries everything forever, which turns a permanent 400 into an infinite loop and hammers a struggling upstream when it is least able to cope. The whole craft here is matching the response to the failure.

The market context is what makes the fifth row โ€” the full outage โ€” newly worth engineering for. Through the first half of 2026 the model landscape stopped being a one-horse race. The teams with the calmest incident channels were the ones who could re-point traffic at a second provider without a deploy, because they had already written the abstraction that made the provider a runtime choice rather than a compile-time constant. For the market backdrop on why provider diversity went mainstream this year, see the multi-provider resilience analysis that runs alongside this tutorial; for the harder edge case of a model you depend on being deprecated out from under you, see the model-continuity piece on planning for a shutdown.

What this client buys you

0 deploys

Re-pointing traffic from a failing provider to a healthy one becomes a runtime decision the client makes on its own, not a code change you ship under incident pressure

โ†‘ 4%composable resilience layers: timeout, retry, breaker, failover

The one abstraction: a Provider interface

Everything in this library hangs off a single interface. If you get this right, every resilience concern below becomes provider-agnostic for free; if you get it wrong, you end up special-casing each upstream in three different places.

// src/types.ts
export interface ChatMessage {
  role: 'system' | 'user' | 'assistant'
  content: string
}

export interface ChatRequest {
  messages: ChatMessage[]
  model?: string
  maxTokens?: number
}

export interface ChatResponse {
  text: string
  model: string
  provider: string
  raw?: unknown
}

export interface Provider {
  readonly name: string
  chat(request: ChatRequest, signal: AbortSignal): Promise<ChatResponse>
}

The important detail is the AbortSignal. A Provider is not just "a function that calls a model" โ€” it is a function that calls a model and promises to stop when told. That promise is what lets the timeout layer actually free the socket instead of leaving a doomed request running in the background, quietly consuming a connection from your pool. Any upstream that can be expressed as "take a request, honor an abort signal, return a response" fits behind this interface: a fetch call, an official SDK client, a local process, or a fake.

Because the whole library speaks only this interface, swapping Anthropic for OpenAI or adding a third fallback is a one-line change to a list. The client never learns the name of a single concrete provider.

Advertisement

Encoding retryability into the error type

The second design decision is where the retry-or-not knowledge lives. The wrong answer is "in a giant if-statement at the call site that string-matches error messages." The right answer is "on the error itself."

// src/errors.ts (abridged)
export class LLMError extends Error {
  readonly retryable: boolean
  constructor(
    message: string,
    retryable: boolean,
    options?: { cause?: unknown }
  ) {
    super(message, options)
    this.name = new.target.name
    this.retryable = retryable
  }
}

export class RateLimitError extends LLMError {
  readonly retryAfterMs?: number
  constructor(message = 'rate limited', retryAfterMs?: number) {
    super(message, true)
    this.retryAfterMs = retryAfterMs
  }
}

export class ServerError extends LLMError {
  constructor(
    message = 'upstream server error',
    readonly status?: number
  ) {
    super(message, true)
  }
}

export class TimeoutError extends LLMError {
  constructor(message = 'request timed out') {
    super(message, true)
  }
}

export class ClientRequestError extends LLMError {
  constructor(
    message = 'client request error',
    readonly status?: number
  ) {
    super(message, false)
  }
}

Now "should I retry this" is a property lookup, not a parse. A RateLimitError and a ServerError and a TimeoutError are all retryable; a ClientRequestError (your 400s, 401s, 404s) is not. A single predicate ties it together and handles the foreign errors โ€” a raw network failure from fetch โ€” that do not subclass our hierarchy:

export function isRetryable(error: unknown): boolean {
  if (error instanceof LLMError) return error.retryable
  if (error instanceof Error) {
    return /ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|ECONNREFUSED|network|fetch failed/i.test(
      error.message
    )
  }
  return false
}

The payoff for this small piece of discipline is large: every layer below makes its decisions by asking one question, and the answer always lives in exactly one place.

Layer 1: a hard per-attempt timeout

The first and most under-appreciated layer is the deadline. A hung request is worse than a failed one, because a failure returns control and a hang does not โ€” it sits on a connection, holds a slot in your concurrency limit, and makes your p99 latency a function of the slowest upstream you ever talk to. The fix is to race every attempt against a timer and abort the loser.

// src/timeout.ts
import { TimeoutError } from './errors.js'

export function withTimeout<T>(
  fn: (signal: AbortSignal) => Promise<T>,
  ms: number
): Promise<T> {
  const controller = new AbortController()
  let timer: ReturnType<typeof setTimeout>

  const timeout = new Promise<never>((_resolve, reject) => {
    timer = setTimeout(() => {
      controller.abort()
      reject(new TimeoutError(`request timed out after ${ms}ms`))
    }, ms)
  })

  return Promise.race([fn(controller.signal), timeout]).finally(() => {
    clearTimeout(timer)
  })
}

Two things make this robust. First, when the timer wins the race it both rejects with a TimeoutError and aborts the signal it handed to fn, so a well-behaved provider tears down its request instead of leaking it. Second, even a provider that ignores the signal entirely still loses the race โ€” the caller is unblocked at the deadline no matter what. The finally clears the timer so a fast success does not leave a dangling handle that keeps the process alive.

Choosing the timeout value is a real decision, not a default to leave at thirty seconds. Set it just above your p99 for normal responses, because a request that has already run longer than ninety-nine percent of successful ones is far more likely to be stuck than to be about to succeed. Cutting it loose and failing over is usually faster than waiting.

Effective p99 latency vs per-attempt timeout when an upstream is degraded (illustrative)

Effective p99 latency vs per-attempt timeout when an upstream is degraded (illustrative)
timeoutMseffectiveP99
20002100
50004200
100006100
3000018000
6000041000

Layer 2: retry with exponential backoff and full jitter

A transient failure deserves another try โ€” but a retry done badly is how a small upstream wobble becomes a self-inflicted outage. The two classic mistakes are retrying things that will never succeed, and retrying everything at the same instant so that the moment a struggling provider recovers, your entire fleet stampedes it in lockstep.

The fixes are exponential backoff (wait longer after each failure) and full jitter (randomize each wait so retries spread out instead of synchronizing). Here is the whole retry function:

// src/retry.ts (abridged)
import { isRetryable as defaultIsRetryable, RateLimitError } from './errors.js'

export async function withRetry<T>(
  fn: (attempt: number) => Promise<T>,
  options: RetryOptions = {}
): Promise<T> {
  const maxAttempts = options.maxAttempts ?? 3
  const baseDelayMs = options.baseDelayMs ?? 200
  const maxDelayMs = options.maxDelayMs ?? 10_000
  const retryable = options.isRetryable ?? defaultIsRetryable
  const sleep = options.sleep ?? defaultSleep
  const random = options.random ?? Math.random

  let lastError: unknown
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn(attempt)
    } catch (error) {
      lastError = error
      if (attempt >= maxAttempts || !retryable(error)) throw error

      const exponential = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1))
      let delayMs = Math.floor(random() * exponential)

      if (
        error instanceof RateLimitError &&
        typeof error.retryAfterMs === 'number'
      ) {
        delayMs = Math.min(maxDelayMs, error.retryAfterMs)
      }

      options.onRetry?.({ attempt, delayMs, error })
      await sleep(delayMs)
    }
  }
  throw lastError
}

Walk the important lines. The loop bails immediately on a non-retryable error, so a 400 fails on the first attempt instead of three times. The backoff is full jitter: the cap doubles each attempt, but the actual delay is a uniform random draw between zero and that cap, which is the variant AWS found spreads retries most evenly. And a Retry-After from a rate-limit response always wins over the computed backoff, because the server just told you exactly how long to wait and guessing is strictly worse than listening.

The other quietly important choice is that sleep and random are injectable. That is not over-engineering โ€” it is what makes the backoff testable without real timers and without flakiness, which we will use in a moment.

Full-jitter backoff: the cap doubles, the actual delay is a random draw beneath it

Full-jitter backoff: the cap doubles, the actual delay is a random draw beneath it
attemptcapMssampleDelayMs
120090
2400210
3800350
41600900
532001500

Layer 3: a circuit breaker per provider

Retries handle a blip. They are exactly the wrong tool for a sustained outage. If a provider has been down for two minutes, then every single request still pays the full timeout-times-retries tax before failing over โ€” and you are hammering a dead endpoint thousands of times while it tries to recover. A circuit breaker fixes this by remembering recent failures and short-circuiting calls to a provider that is clearly sick.

It is a three-state machine. Closed is normal: requests flow and consecutive failures are counted. After enough failures the breaker trips open: requests are rejected instantly, without even attempting the call, for a cooldown period. When the cooldown elapses the breaker goes half-open and allows a probe or two through; if they succeed it closes, if any fails it snaps back open.

// src/circuit-breaker.ts (abridged)
export class CircuitBreaker {
  private state: CircuitState = 'closed'
  private failures = 0
  private successes = 0
  private openedAt = 0

  constructor(private readonly options: CircuitBreakerOptions = {}) {
    this.failureThreshold = options.failureThreshold ?? 5
    this.cooldownMs = options.cooldownMs ?? 30_000
    this.successThreshold = options.successThreshold ?? 1
    this.now = options.now ?? Date.now
  }

  canRequest(): boolean {
    if (this.state === 'open') {
      if (this.now() - this.openedAt >= this.cooldownMs) {
        this.state = 'half-open'
        this.successes = 0
        return true
      }
      return false
    }
    return true
  }

  onSuccess(): void {
    if (this.state === 'half-open') {
      if (++this.successes >= this.successThreshold) this.close()
    } else {
      this.failures = 0
    }
  }

  onFailure(): void {
    if (this.state === 'half-open') return this.open()
    if (++this.failures >= this.failureThreshold) this.open()
  }
}

Notice that now is injectable, exactly like sleep and random were. The breaker is a time-dependent state machine, and the only way to test "it reopens after the cooldown" without making the test suite sleep for thirty real seconds is to control the clock. We will.

One subtlety worth calling out: in our client the breaker counts one failure per exhausted provider, not one per retry. So failureThreshold: 5 means "five chat() calls in a row could not get an answer out of this provider," which is the signal you actually care about, rather than "five retries within one unlucky request," which is noise.

Closed

Normal operation

Requests flow; consecutive failures are counted toward the threshold

Open

Tripped after threshold

Requests are rejected instantly for the cooldown window โ€” no call is attempted, so no timeout is paid

Half-open

Cooldown elapsed

A limited number of probe requests are allowed through to test whether the provider has recovered

Closed again

Probe succeeds

Enough successful probes close the circuit and normal traffic resumes; any failure snaps it back open

Advertisement

Composing it: the ResilientClient and failover

Now the four layers come together. The client holds an ordered list of providers and a circuit breaker per provider. For each request it walks the chain: skip any provider whose breaker is open, otherwise run that provider wrapped in a timeout wrapped in retry, and on success return. If a provider is exhausted, record one failure on its breaker and fall over to the next. If every provider fails, throw an aggregate error that carries all the causes.

// src/client.ts (the core loop)
async chat(request: ChatRequest): Promise<ChatResponse> {
  const failures: Array<{ provider: string; error: unknown }> = [];

  for (let i = 0; i < this.providers.length; i++) {
    const provider = this.providers[i];
    const breaker = this.breakers.get(provider.name)!;

    if (!breaker.canRequest()) {
      failures.push({ provider: provider.name, error: new CircuitOpenError(provider.name) });
      this.failover(i, new CircuitOpenError(provider.name));
      continue;
    }

    try {
      const response = await withRetry(
        () => withTimeout((signal) => provider.chat(request, signal), this.timeoutMs),
        this.retry,
      );
      breaker.onSuccess();
      return response;
    } catch (error) {
      breaker.onFailure();
      failures.push({ provider: provider.name, error });
      this.onProviderError?.({ provider: provider.name, error });
      this.failover(i, error);
    }
  }

  throw new AllProvidersFailedError(failures);
}

The nesting order matters and is easy to get backwards. Timeout is the innermost wrapper, so it applies to each individual attempt; retry wraps timeout, so each retry gets its own fresh deadline; the breaker sits outside both, recording a single verdict per provider after retries are spent. Read the composition out loud and it is exactly the policy you want: "try this provider, giving each attempt its own deadline and retrying transient failures a few times; if the whole provider is exhausted, remember that and move to the next one."

The aggregate AllProvidersFailedError is the small touch that makes 3 a.m. debugging bearable. When everything is down you do not get a generic 500 โ€” you get one error object that names every provider that failed and why, so the on-call engineer can tell "all three providers rate-limited us simultaneously" from "our API key is revoked" in one glance.

Matching the response to the failure

Rate limit (429)Retry after the server-provided delay; count toward the breaker
Transient 5xx / 529Retry with backoff and jitter; count toward the breaker
TimeoutAbort the socket, retry once or twice, then fail over
Bad request (4xx)Do not retry โ€” fail fast, it will not fix itself
Provider outageBreaker trips open; skip the provider entirely until cooldown

Plugging in a real provider

The whole point of the Provider interface is that a real upstream is a thin adapter. Here is the Anthropic one, written with fetch so the library stays dependency-free; in a larger app you would more often wrap the official SDK client inside this same shape. The only real work is translating HTTP status codes into the typed, retryable errors the rest of the system already understands.

// src/providers/anthropic.ts (abridged)
export class AnthropicProvider implements Provider {
  readonly name = 'anthropic'

  async chat(request: ChatRequest, signal: AbortSignal): Promise<ChatResponse> {
    const model = request.model ?? this.model // defaults to claude-opus-4-8
    const system = request.messages
      .filter(m => m.role === 'system')
      .map(m => m.content)
      .join('\n\n')
    const messages = request.messages
      .filter(m => m.role !== 'system')
      .map(m => ({ role: m.role, content: m.content }))

    const response = await this.fetchFn(`${this.baseUrl}/v1/messages`, {
      method: 'POST',
      signal,
      headers: {
        'content-type': 'application/json',
        'x-api-key': this.apiKey,
        'anthropic-version': '2023-06-01',
      },
      body: JSON.stringify({
        model,
        max_tokens: request.maxTokens ?? 1024,
        system,
        messages,
      }),
    })

    if (!response.ok) throw await this.toError(response)

    const data = await response.json()
    const text = data.content
      .filter(b => b.type === 'text')
      .map(b => b.text)
      .join('')
    return { text, model: data.model, provider: this.name, raw: data }
  }
}

The status-code mapping is where the resilience policy connects to reality. A 429 becomes a RateLimitError that reads the Retry-After header so the retry layer can honor it. A 500 or 529 becomes a retryable ServerError. Anything else in the 4xx range becomes a non-retryable ClientRequestError, because a malformed request or a bad key will fail identically forever and retrying it only wastes time and quota.

private async toError(response: Response): Promise<Error> {
  const message = `anthropic ${response.status}`;
  if (response.status === 429) {
    const header = response.headers.get('retry-after');
    const retryAfterMs = header ? Number(header) * 1000 : undefined;
    return new RateLimitError(message, retryAfterMs);
  }
  if (response.status >= 500) return new ServerError(message, response.status);
  return new ClientRequestError(message, response.status);
}

Note that fetchFn is injected with a default of the global fetch. That is the same testability trick again: a test can pass a fake fetch that returns a 429 with a Retry-After header and assert the adapter produces the right typed error, all without a network or an API key. A second provider โ€” OpenAI, a local model, a hosted reseller โ€” is just another file implementing the same interface, and adding it to the chain is one line.

Testing it deterministically

This is the part proprietary gateways cannot give you: a test suite that exercises every resilience path in milliseconds, with no network, no API key, and no flakiness. The injectable sleep, random, and now we threaded through every layer are what make it possible. The companion repo ships nineteen tests across four files; here are the load-bearing ones.

A scriptable FakeProvider is the workhorse. You hand it a list of behaviors and each chat() call consumes the next one, so a test can say "fail twice with a 503, then succeed" with total precision:

const flaky = new FakeProvider('primary', [
  { type: 'error', error: new ServerError('down', 503) },
  { type: 'error', error: new ServerError('down', 503) },
  { type: 'ok', text: 'recovered' },
])

Retry math becomes a pure assertion once random is fixed and sleep is a no-op. With random pinned to 0.5 and a 200 ms base, the first backoff cap is 200 ms so the delay is 100 ms, and the second cap is 400 ms so the delay is 200 ms โ€” exactly, every run:

it('uses full-jitter exponential backoff with the injected random', async () => {
  const delays: number[] = []
  await withRetry(failTwiceThenSucceed, {
    maxAttempts: 3,
    baseDelayMs: 200,
    random: () => 0.5,
    sleep: async () => {},
    onRetry: ({ delayMs }) => delays.push(delayMs),
  })
  expect(delays).toEqual([100, 200]) // deterministic
})

The circuit breaker is tested by controlling its clock. "It reopens after the cooldown" would be a thirty-second test against a real clock; with an injected now it is instant:

let now = 0
const cb = new CircuitBreaker({
  failureThreshold: 1,
  cooldownMs: 1000,
  now: () => now,
})
cb.onFailure()
expect(cb.canRequest()).toBe(false) // open, still in cooldown
now = 1000
expect(cb.canRequest()).toBe(true) // cooldown elapsed -> half-open

And failover is tested end to end with two fakes โ€” primary always fails, secondary succeeds โ€” asserting that the response comes from the secondary and that, after enough failures, the primary's breaker is open and it is skipped entirely:

await client.chat(request)
await client.chat(request)
expect(client.breakerState('flaky')).toBe('open')
const res = await client.chat(request)
expect(res.provider).toBe('backup') // primary skipped โ€” breaker open

The whole suite runs in a few seconds and never touches a token.

Tutorial roadmap โ€” all six parts are covered end to end

1. Provider interface + typed errors100.0%
2. Per-attempt timeout100.0%
3. Retry with backoff + jitter100.0%
4. Per-provider circuit breaker100.0%
5. Failover client100.0%
6. Real adapter + deterministic tests100.0%

Production considerations

A few things separate a tutorial client from one you trust with real traffic.

Order the chain by cost and capability, not just availability. Failover is not free โ€” your fallback may be slower, pricier, or slightly less capable. Put your preferred provider first and treat the rest as a graceful-degradation ladder, not equals. If correctness matters more than cost on a given route, you might even run the request against two providers and take the first to answer; if cost dominates, keep the cheap model primary and only climb on failure. The same approach that makes a swap safe โ€” a regression gate you can run against any provider โ€” is worth building alongside this; see the LLM evaluation harness tutorial for that half of the story.

Tune the breaker to your traffic. A failureThreshold of 5 and a 30-second cooldown are sane defaults for steady traffic, but a low-volume endpoint may trip too slowly to matter and a high-volume one may want a shorter cooldown so it recovers faster. The values are configuration, not constants, precisely so you can turn these knobs per deployment without touching the library.

Make idempotency a precondition of retry. Retrying a chat completion is safe because it has no side effects. The moment a request triggers a tool call that charges a card or sends an email, blind retry becomes dangerous. Keep the retry layer on the model call itself and put side effects behind their own idempotency keys.

Emit the hooks. The client exposes onProviderError and onFailover callbacks for exactly one reason: a failover that happens silently is a failover you discover from a billing surprise. Wire them to your metrics so "we have been serving every request from the backup provider for six hours" shows up on a dashboard, not in a postmortem.

Where this is all heading is a world where multi-provider routing is the assumed default for any serious AI application rather than a resilience nicety โ€” a claim I make precise, dated, and falsifiable in this prediction on multi-provider failover becoming standard.

Conclusion

A resilient LLM client is not a framework and not a product. It is four small, boring layers โ€” a deadline, a retry loop, a breaker, and an ordered chain โ€” each of which does one thing and composes cleanly with the others. Written behind a single Provider interface, the whole thing is about two hundred and fifty lines, depends on nothing, talks to any upstream, and is tested without spending a cent. The payoff is that the question that used to cause incidents โ€” "what do we do when the provider is down" โ€” already has an answer your code executes on its own.

Clone the companion repo, run the test suite, then point a real adapter at your provider and add a second one behind it. The day the first provider has a bad hour, your users will not notice, and that quiet non-event is the entire return on a couple hundred lines of unglamorous code.

Further reading

  • Plan for the provider you depend on going away โ€” the model-continuity case for treating provider risk as an engineering problem, which this client operationalizes.
  • Build an LLM-as-Judge Evaluation Harness in TypeScript โ€” the regression gate that makes swapping providers safe, and a sibling small, tested, provider-agnostic primitive.
  • Multi-provider resilience and the 2026 model fragmentation โ€” the market context for why provider diversity went mainstream this year.
  • Prediction: multi-provider failover becomes the default for enterprise LLM apps by the end of 2027 โ€” my dated, falsifiable claim on where this is heading.
  • Companion code on GitHub โ€” the full, tested project from this tutorial.

Signed by Michael Eakins

PGP key fingerprint ends in 08E8 8F19 ยท signed 2026-06-22

Verify โ†’.sig
Advertisement

Was this article helpful?

Your feedback helps us improve our content and create more valuable resources

We appreciate honest feedback - it helps us serve you better

Work with us

This analysis is what we do for clients

CrashBytes consults on enterprise AI strategy and implementation, builds custom web and mobile software, and places senior engineers on corp-to-corp engagements.

See Services

Enjoyed this? Get the next one.

Join developers getting CrashBytes articles, tutorials, and predictions in their inbox. No spam, unsubscribe anytime.

Related Topics

TypeScriptLLMresiliencereliabilitytutorial
Back to Articles
โ† PreviousThe Self-Driving Lab Arrives - What Autonomous AI Drug Discovery Actually Changed in 2026Next โ†’The Document Layer Dissolves: Reading Paper Just Became a Commodity

From across the CrashBytes network

More than the blog โ€” predictions, news, fiction, and AI art.

PredictionCustom AI Chips Reach Commodity Status by Q4 2027: Cloud Provider Competition Drives Democratization
NewsWeek In Review July 19-25, 2026 - The Week The Money Moved To The Metering Layer
Short StoryThe Answer Key
AI ArtThe Room That Remembers

Continue Your Learning Journey

Explore more articles related to Engineering and expand your knowledge.

๐Ÿ“„Engineering

Build a Type-Safe LLM Tool-Calling Layer in TypeScript: Zod Validation and Auto-Repair

A hands-on TypeScript tutorial: validate LLM tool-call arguments with Zod, auto-repair malformed calls in a bounded loop, and dispatch fully-typed args to your handlers โ€” provider-agnostic, tested, and offline.

41 min readRead more
๐Ÿ“„Tutorial

Parse Streaming JSON From an LLM: A Tolerant Partial-JSON Parser in TypeScript

A hands-on TypeScript tutorial that builds a tolerant partial-JSON parser so an LLM's streamed structured output renders field by field instead of stalling on the closing brace. Tokenizer, auto-closing parser, and 49 offline tests.

21 min readRead more
๐Ÿ“„Tutorial

Cut LLM Token Costs with Anthropic Prompt Caching in TypeScript

A hands-on TypeScript tutorial on Anthropic prompt caching. Build a cache-aware request planner, audit for silent invalidators, and measure real savings from the usage fields, offline and fully tested.

27 min readRead more
๐Ÿ“„Engineering

Build an LLM-as-Judge Evaluation Harness in TypeScript: Scorers, Rubrics, and a CI Gate

A hands-on TypeScript tutorial for building an LLM evaluation harness: deterministic scorers, an LLM-as-judge rubric scorer, a concurrent runner with retries, and a CI regression gate โ€” provider-agnostic, fully tested.

28 min readRead more