Qora API — AI API Gateway for Developers

AI API Gateway for Developers

One clear API workflow for your apps, scripts and automations.

How to Build a Multi-Provider AI Failover Layer for 99.9% Uptime

Diagram cover reading Build a Multi-Provider AI Failover Layer — circuit breakers, health checks and fallback routing for AI APIs

A multi-provider AI failover layer routes each request through a provider selector, tracks per-provider health with circuit breakers, and retries on the next capable provider when one errors, times out, or gets rate-limited. The result: a single vendor outage degrades latency or quality instead of taking your feature down.

This guide is the implementation, not the pitch: the real failure modes, a working CircuitBreaker class, a decision table for fallback strategies, and a fault-injection test you can run in CI.

Why a single AI provider is a single point of failure

“The API is down” is the failure mode everyone plans for and the one that causes the least damage, because it announces itself. The expensive failures are the quiet ones. Four categories matter in production:

  • Hard outages. Provider-side 5xx bursts and latency cliffs lasting minutes to hours. Your own retry loop makes this worse: retrying the same provider multiplies load against a system already shedding traffic, and converts a 4-second degradation into a 60-second timeout for your users.
  • Silent deprecations and behavior drift. Your pinned model alias gets repointed to a new snapshot. No error, no status-page entry — just a shift in tool-calling reliability, JSON conformance, or instruction adherence. Error-rate dashboards stay green while your agent starts dropping function calls. The only detector is a golden-set eval, not a 5xx counter.
  • Shared rate limits. Quotas are enforced per organization or per key, not per service. Your nightly batch job and your interactive chat endpoint draw from the same tokens-per-minute bucket, so a 429 on the user-facing path is frequently caused by your own cron. No amount of retrying fixes a budget you already spent; only a second provider does.
  • Regional blocks and egress failures. An endpoint can be unreachable or degraded from one region while healthy from another, blocked by corporate egress rules, or legally unusable for a subset of your traffic under data-residency policy. Same code, same key, different availability depending on where it runs.
Failure modeWhat you observeWhy retrying the same provider fails
Hard outage5xx spike, p99 latency cliffAdds load to a shedding system; extends your own timeout
Silent deprecationFlat error rate, rising eval failuresNothing to retry — the response is a 200 with worse content
Shared rate limit429s correlated with internal batch jobsThe quota is spent; retries stay 429 until the window resets
Regional blockConnection timeouts from one region onlyThe path, not the request, is broken
Auth/key revocation401/403 across every caller at onceDeterministic failure — retries never succeed

Three of those five rows are not solved by retrying. That is the argument for a failover layer: retries fix transient faults, failover fixes provider faults.

What a failover layer actually does

A failover layer is not a try/except around the SDK. It is a five-stage pipeline that sits between your application and every provider you use:

  • 1. Normalize the request. Convert your internal request into a provider-neutral shape (messages, tools, max tokens, stream flag) plus a capability descriptor — “needs tool calling, vision, 128k context”. That descriptor is what makes capability-based routing possible.
  • 2. Select candidates. Ask the policy for an ordered list of providers satisfying the capability descriptor, filtered by breaker state. Order comes from your routing policy — see model routing for how to build that ordering from quality, cost, and latency.
  • 3. Gate on health. Each candidate’s circuit breaker decides whether it may receive traffic right now. An open breaker means “skip this provider entirely” — you never open a socket.
  • 4. Attempt and classify. Send the request, then classify the outcome. This is the stage most implementations get wrong. A 429 or 503 is a provider fault. A 400 malformed-request or content-policy rejection is your fault: every other provider would reject it identically, so it must not trip a breaker or trigger failover. A timeout is ambiguous — treat it as a provider fault for routing purposes, but never blindly retry non-idempotent work.
  • 5. Record and normalize the response. Update breaker and latency statistics, tag the response with which provider actually answered, and return a provider-neutral object so callers never branch on vendor.

Two rules keep this pipeline honest. First, streaming can only fail over before the first token. Once you have emitted bytes to the client you cannot transparently switch providers without corrupting the response — close the stream and let the client retry. If you are building streaming endpoints, read the wire-format and proxy-buffering details in our streaming / SSE guide before wiring failover around it. Second, failover must not duplicate side effects. If a request triggers a write (a tool call that charges a card, creates a record, sends a message), failing over after a timeout can execute it twice. Either make those paths non-failover with a hard error, or attach an idempotency key that every provider in the chain honors.

Provider health checks and circuit breakers

Prefer passive health signals over active probes. A one-token ping tells you the provider accepted a connection, not that the model is producing usable output — and it costs quota to run at any meaningful frequency. Your real traffic is already the best health probe you have. Reserve active probing for chain members you have not used recently: one cheap request every few minutes keeps a standby warm enough to trust.

A circuit breaker converts those signals into a routing decision, with three states: closed (traffic flows), open (traffic is refused immediately, no network call), and half-open (exactly one probe is allowed through to test recovery). Two details separate a working breaker from a toy: the cooldown backs off exponentially so a flapping provider is retried progressively less often, and half-open admits only one request at a time — otherwise a burst of traffic probes at once and you re-create the outage you were protecting against.

import random
import threading
import time
from collections import deque
from enum import Enum


class State(str, Enum):
    CLOSED = "closed"        # healthy: all traffic allowed
    OPEN = "open"            # failing: refuse traffic, fail over immediately
    HALF_OPEN = "half_open"  # probing: exactly one request allowed through


class CircuitBreaker:
    """Passive circuit breaker for one AI provider.

    failure_threshold : failures inside window_s before the breaker opens
    base_cooldown_s   : first cooldown; doubles on each consecutive trip
    half_open_successes: clean probes required before closing again
    """

    def __init__(self, name, failure_threshold=5, window_s=60.0,
                 base_cooldown_s=5.0, max_cooldown_s=120.0,
                 half_open_successes=2, clock=time.monotonic):
        self.name = name
        self.failure_threshold = failure_threshold
        self.window_s = window_s
        self.base_cooldown_s = base_cooldown_s
        self.max_cooldown_s = max_cooldown_s
        self.half_open_successes = half_open_successes
        self._clock = clock
        self._lock = threading.Lock()
        self._events = deque()          # (timestamp, ok)
        self._state = State.CLOSED
        self._opened_at = 0.0
        self._consecutive_trips = 0
        self._probe_successes = 0
        self._probe_in_flight = False

    @property
    def state(self):
        with self._lock:
            self._maybe_half_open()
            return self._state

    def _cooldown(self):
        # A provider that trips repeatedly is probed less and less often.
        return min(self.base_cooldown_s * (2 ** self._consecutive_trips),
                   self.max_cooldown_s)

    def _maybe_half_open(self):
        if self._state is State.OPEN and self._clock() - self._opened_at >= self._cooldown():
            self._state = State.HALF_OPEN
            self._probe_successes = 0
            self._probe_in_flight = False

    def allow(self):
        """True if a request may be attempted against this provider now."""
        with self._lock:
            self._maybe_half_open()
            if self._state is State.CLOSED:
                return True
            if self._state is State.OPEN:
                return False
            # HALF_OPEN: admit a single probe so recovery is not a stampede.
            if self._probe_in_flight:
                return False
            self._probe_in_flight = True
            return True

    def record(self, ok):
        with self._lock:
            now = self._clock()
            if self._state is State.HALF_OPEN:
                self._probe_in_flight = False
                if not ok:
                    self._open(now)                 # one bad probe re-opens it
                    return
                self._probe_successes += 1
                if self._probe_successes >= self.half_open_successes:
                    self._state = State.CLOSED
                    self._consecutive_trips = 0
                    self._events.clear()
                return

            self._events.append((now, ok))
            cutoff = now - self.window_s
            while self._events and self._events[0][0] < cutoff:
                self._events.popleft()
            failures = sum(1 for _, ok_ in self._events if not ok_)
            if failures >= self.failure_threshold:
                self._open(now)

    def _open(self, now):
        self._state = State.OPEN
        self._opened_at = now
        self._consecutive_trips += 1
        self._probe_in_flight = False


BREAKERS = {name: CircuitBreaker(name) for name in PROVIDER_CHAIN}


def call_with_failover(messages, candidates, timeout=8.0):
    """Walk the candidate chain until a healthy provider answers."""
    last_error = None
    for attempt, provider in enumerate(candidates):
        breaker = BREAKERS[provider]
        if not breaker.allow():
            continue                              # open breaker: no socket opened
        try:
            response = PROVIDERS[provider].chat(messages, timeout=timeout)
            breaker.record(True)
            return response, provider
        except ProviderFault as exc:              # 5xx, 429, timeout, connection reset
            breaker.record(False)
            last_error = exc
            backoff = min(0.2 * (2 ** attempt), 2.0)
            time.sleep(backoff * (0.5 + random.random()))   # full jitter
        except CallerFault:                       # 400, content policy, bad schema
            breaker.record(True)                  # provider is fine: do not trip it
            raise
    raise AllProvidersUnavailable(last_error)

The except CallerFault: breaker.record(True) line is the one people miss. If a malformed request trips your breaker, a single buggy caller can mark a perfectly healthy provider as down and push all traffic to your expensive fallback. Classify by whose fault it is, not by whether the call raised.

Fallback routing strategies

Once the pipeline exists, the only open question is what order the candidate list should be in. Four strategies cover essentially every production workload, and they compose: use one as the primary ordering and another as the tiebreak.

StrategyTriggerPicksBest forWatch out for
Error-based5xx, 429, timeout, connection resetNext provider in a static orderDefault for most apps; trivial to reason aboutEvery client fails over to the same standby at once — a shared outage becomes a stampede
Latency-basedRolling p95 time-to-first-token per providerFastest healthy providerInteractive chat, autocomplete, voiceNoisy at low volume; smooth with an EWMA and require a minimum sample count before switching
Cost-basedPer-request token estimate crosses a tier boundaryCheapest healthy provider that clears the quality barBatch, offline enrichment, bulk summarizationQuality drifts downward silently — gate it behind evals, never behind price alone
Capability-basedRequest needs tools, vision, JSON schema, or a long contextOnly providers whose capability matrix satisfies the descriptorAgents, multimodal input, structured extractionThe capability matrix drifts as providers ship updates — regenerate it, do not hand-maintain it

Three decision criteria that matter more than the strategy name:

  • Keep the fallback in the same quality tier. Failing over from a frontier model to a small/fast model changes output length, formatting, and reasoning depth. If a downstream parser or a user-visible contract depends on that, a “successful” failover is a correctness bug. Chain within a tier, and treat cross-tier degradation as an explicit, logged product decision.
  • Cap the chain length at three. Each hop adds its timeout to the worst case. Three providers at an 8-second timeout means a user can wait 24 seconds before seeing an error. Set a total request budget and abort the chain when it is exhausted rather than trying every candidate.
  • Diversify infrastructure, not just vendor names. Two models served through the same upstream account share quota and share the outage. Verify your primary and secondary do not resolve to the same rate-limit bucket or regional egress path.

How a unified gateway turns this into one config

Everything above assumes you can call many providers from one place. Without a gateway, that is the expensive part: N SDKs, N auth schemes, N error taxonomies, N retry semantics, N token-counting conventions. Your failover layer has to encode all of it, and every provider you add is a code change plus a test matrix.

An OpenAI-compatible gateway collapses that surface to one. One base URL, one API key, one request shape, one error taxonomy that is already OpenAI-shaped — which means the breaker’s ProviderFault classifier, the latency tracker, and the selector all become provider-agnostic code that never changes when you add a vendor. Moving a fallback chain from one vendor to another becomes editing a list of model strings, the same discipline described in switch AI providers without rewriting code.

import os
import openai

# One client, one key, many providers behind it.
client = openai.OpenAI(
    base_url="https://qoraapi.com/v1",
    api_key=os.environ["QORA_API_KEY"],
    timeout=8.0,
    max_retries=0,   # the circuit breaker owns retries, not the SDK
)

# Failover = ordering this list. No SDK swaps, no auth changes, no new clients.
PROVIDER_CHAIN = [
    "gpt-4o",              # primary
    "claude-3-5-sonnet",   # different vendor, same quality tier
    "gemini-2.0-flash",    # cheaper degrader for non-critical traffic
]


def chat(messages, model=None):
    for candidate in ([model] if model else PROVIDER_CHAIN):
        try:
            return client.chat.completions.create(
                model=candidate, messages=messages
            )
        except openai.RateLimitError:
            continue          # provider fault: try the next candidate
        except openai.APIStatusError as exc:
            if exc.status_code in (500, 502, 503, 504, 529):
                continue      # provider fault: try the next candidate
            raise             # caller fault: fail over would be pointless
    raise RuntimeError("no provider in the chain could serve this request")

Two configuration choices there are deliberate. max_retries=0 disables the SDK’s built-in retry loop, because two stacked retry layers multiply worst-case latency and hide the failure counts your breaker needs to see. And the chain is a plain list, not a hardcoded branch — which is what lets a gateway put qoraapi.com in front of many models through a single OpenAI-compatible key, so failover policy lives in your config while provider onboarding lives in the gateway.

Split the responsibilities deliberately: let the gateway own provider-level concerns — credentials, regional egress, quota pooling, retrying a different upstream of the same model family. Let your client-side layer own policy — which tier a task deserves, when to degrade quality, what latency is acceptable. That division keeps application code stable while the provider landscape churns.

Testing your failover with fault injection

An untested failover path is a liability, because it only executes during an incident — the worst moment to discover your fallback provider’s SDK takes different parameters. Test it in CI by injecting faults at the client. Wire an environment-variable-driven fault mode into your provider adapter so tests can force timeouts, 503s, and 429s on any named provider.

import pytest


def test_fails_over_when_primary_times_out(monkeypatch):
    monkeypatch.setenv("FAULT__primary", "timeout")
    response, provider = call_with_failover(MESSAGES, PROVIDER_CHAIN)
    assert provider != "primary"          # traffic actually moved
    assert response.choices[0].message.content   # and a real answer came back


def test_caller_error_does_not_trip_the_breaker():
    breaker = BREAKERS["primary"]
    with pytest.raises(CallerFault):
        call_with_failover([{"role": "user", "content": None}], PROVIDER_CHAIN)
    assert breaker.state is State.CLOSED  # healthy provider stays in rotation


def test_breaker_opens_then_recovers(monkeypatch):
    breaker = BREAKERS["primary"]
    for _ in range(breaker.failure_threshold):
        breaker.record(False)
    assert breaker.state is State.OPEN
    assert breaker.allow() is False       # open: request never leaves the process

    # Jump past the cooldown and verify the half-open probe gate.
    monkeypatch.setattr(breaker, "_clock",
                        lambda: breaker._opened_at + breaker.max_cooldown_s + 1)
    assert breaker.allow() is True        # first probe admitted
    assert breaker.allow() is False       # concurrent probe refused
    breaker.record(True)
    breaker.record(True)
    assert breaker.state is State.CLOSED


def test_all_providers_down_degrades_gracefully(monkeypatch):
    for name in PROVIDER_CHAIN:
        monkeypatch.setenv(f"FAULT__{name}", "503")
    with pytest.raises(AllProvidersUnavailable):
        call_with_failover(MESSAGES, PROVIDER_CHAIN)
    # Assert your product-level behaviour here: cached answer, queue-for-later,
    # or a typed error the UI knows how to render. Never an unhandled 500.

The four tests worth keeping permanently: failover produces a real answer, a caller fault does not trip a healthy breaker, the breaker opens and recovers through half-open, and the total-outage path returns a deliberate degraded response. The fourth is the one teams skip, and it is the one your users experience during a multi-provider incident.

When you don’t need a failover layer

Failover is not free. It adds a state machine, a telemetry surface, and — most importantly — a class of bugs that only appears during incidents. Skip it when any of these apply:

  • A failed call degrades to nothing. If the feature is a suggestion, a draft, or an optional enrichment that the UI can simply omit, a clean error is a better product than a slower, different-quality answer from a second vendor.
  • The work is already retryable and non-urgent. A nightly batch job that can re-run in the morning needs exponential backoff and a dead-letter queue, not a circuit breaker.
  • You are pre-product-market-fit. Under roughly a thousand requests a day with no SLA attached to the output, your engineering hours buy more uptime spent on the core feature than on a multi-provider router.
  • The side effects are non-idempotent and unkeyed. If failing over can double-charge, double-send, or double-create, a hard failure is strictly safer than a transparent retry. Fix idempotency before you add providers.
  • One provider is the product. If you are selling a specific model’s behavior, a fallback to a different model changes what you sold. Surface the outage instead of quietly substituting.

A useful threshold: build it when the expected cost of a failed request — retries, support load, abandoned sessions, broken downstream jobs — exceeds the cost of maintaining the layer. For a user-facing assistant that arrives fast; for an internal batch pipeline, it may never arrive.

Frequently asked questions

How many providers should be in a failover chain?

Three: a primary, a same-tier secondary on different infrastructure, and one cheap degrader for non-critical traffic. A fourth hop adds tail latency without adding real resilience, because the failure modes that take out three independent providers at once are the same ones that would take out the fourth. Spend the effort on diversifying quota buckets and egress paths instead of adding vendor names.

What timeout should I use before failing over?

Derive it from your own data: measure the p99 time-to-first-token of a healthy provider and set the failover timeout at roughly 1.5× that value, then set a hard total budget for the whole chain. Use a short connect timeout (1–2 seconds) so a broken network path fails fast, and never let the sum of per-provider timeouts exceed the total budget your UI can tolerate.

Should a 429 trigger failover?

Yes, but treat it as a soft signal rather than a hard outage. A 429 means this key has spent its quota in this window, so failing over is correct — but it should not open a breaker for a full cooldown, because the provider itself is healthy. Combine a short jittered backoff with failover, and read our guide on how to handle 429 errors for the quota-sharing patterns that stop your own batch jobs from starving your interactive endpoints.

Will failover change my application’s output?

Yes. Different providers produce different wording, formatting, and tool-calling reliability even at identical temperature and prompts. Mitigate it three ways: keep every provider in a chain within the same quality tier, validate each candidate against the same golden-set eval before promoting it, and log which provider answered each request so quality regressions are traceable to a failover event.

Conclusion

Build the failover layer in four pieces: a normalized request with a capability descriptor, a candidate list ordered by an explicit policy, a passive circuit breaker per provider with an exponential cooldown and a single half-open probe, and a fault-injection test suite that proves the fallback path works before you need it. Classify errors by whose fault they are, cap the chain at three, and put a unified OpenAI-compatible gateway in front so adding or swapping a provider is a config edit rather than a refactor.

Then verify it the only way that counts: force your primary provider to fail in a test, and confirm your users never notice.

Related reading

Build AI features with one clear API

Qora API gives you a single, focused gateway to connect your apps, scripts and automations to AI. Start with one request.

qoraapi.com · AI API gateway for developers

Comments

5 responses to “How to Build a Multi-Provider AI Failover Layer for 99.9% Uptime”

  1. […] reroutes on 429, 5xx, or timeout. That needs a capability registry, not a retry loop; our guide to multi-provider failover covers the circuit-breaker […]

  2. […] How to Build a Multi-Provider AI Failover Layer for 99.9% Uptime […]

  3. […] gateway that already implements this logic saves you from rebuilding it in every service. See building a multi-provider AI failover layer and handling 429 rate limit […]

  4. […] candidate model, an outage or a burst of 429s becomes a degraded path instead of an incident. Our multi-provider failover guide covers that side in […]

  5. […] the value. First, budget_ratio: without it, retries are unbounded amplification, as documented in the multi-provider failover playbook. Second, weight: 0 on the fallbacks – a fallback receiving steady traffic is not a fallback […]

Leave a Reply

Your email address will not be published. Required fields are marked *