Every multi-provider LLM deployment eventually hits a provider outage, a 429, or a silently degraded endpoint. The failover design latency tradeoff is the price you pay for resilience: a small steady-state overhead in exchange for avoiding total failure when an upstream breaks. This guide gives an ordered path to implement failover without blindly adding seconds to your p99.
Step 1: Baseline your single-provider latency
You cannot reason about overhead you haven’t measured. Point a minimal client at your primary provider and record p50, p95, and p99 for a representative prompt. Use a fixed input size and the same sampling params every run.
import time, statistics
from openai import OpenAI
client = OpenAI(base_url="https://api.primary.example/v1", api_key="sk-...")
prompt = "Summarize: " + "x" * 200
def call():
t0 = time.perf_counter()
client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
max_tokens=64,
)
return time.perf_counter() - t0
samples = [call() for _ in range(100)]
print("p50", statistics.median(samples))
print("p99", sorted(samples)[int(0.99*len(samples))])
Run this against each candidate provider. The spread between their p99s is your first input to the failover design latency tradeoff: if the secondary is 300 ms slower at p99, sequential fallback will hurt exactly when you’re already in trouble.
Step 2: Define failure precisely
“Failover” is meaningless until you specify the trigger. Distinguish three cases:
- Hard error: connection refused, 5xx, non-200 JSON.
- Rate limit: 429 or 403 with retry-after.
- Semantic degradation: responses arrive but violate a latency SLA or return empty content.
Only the first two should immediately switch providers. The third requires a budgeted check, otherwise you’ll flip-flop under mild load.
def is_failure(resp, elapsed, sla_ms):
if resp is None:
return True
if resp.status_code >= 500:
return True
if resp.status_code == 429:
return True
if elapsed * 1000 > sla_ms:
return True # semantic degradation
return False
Set sla_ms from your baseline p99 plus a margin, not from hope.
Step 3: Order fallbacks by latency tier
Build an explicit priority list. Put the lowest-latency compliant provider first; put a cheaper but slower one later only if it meets your hard product limit.
{
"fallback_order": [
{"provider": "primary", "model": "gpt-4o-mini", "p99_ms": 220},
{"provider": "secondary", "model": "claude-3-haiku", "p99_ms": 340},
{"provider": "tertiary", "model": "mixtral-8x7b", "p99_ms": 480}
]
}
The failover design latency tradeoff gets worse with each step down this list. If the tertiary is your only backup, you’ve accepted a 2x p99 during incidents. That’s a product decision, not a default.
Step 4: Set timeout and retry budgets
Never use the SDK’s default retries in a failover path. They multiply latency before you switch. Disable them and enforce a single bounded timeout per attempt.
from openai import OpenAI
client = OpenAI(
base_url="https://api.primary.example/v1",
api_key="sk-...",
max_retries=0,
timeout=0.8, # seconds, derived from sla_ms
)
If the call throws APITimeoutError or APIConnectionError, catch and move to the next entry in fallback_order. Keep the total budget across all attempts under your user-visible timeout (typically 2–3 seconds for chat).
Step 5: Use hedged requests to cap tail latency
Sequential fallback punishes the user for the primary’s slowness even when the primary would have eventually answered. Hedging issues a parallel request to the secondary after a short delay (e.g., 150 ms) and takes whichever finishes first.
import asyncio
from openai import AsyncOpenAI
async def hedged_call(primary, secondary, delay=0.15):
async def prim():
return await primary.chat.completions.create(model="gpt-4o-mini", messages=MSG)
async def sec():
await asyncio.sleep(delay)
return await secondary.chat.completions.create(model="claude-3-haiku", messages=MSG)
done, pending = await asyncio.wait(
[asyncio.create_task(prim()), asyncio.create_task(sec())],
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
return list(done)[0].result()
This changes the failover design latency tradeoff: you pay for duplicate tokens on the slow path, but you erase most of the p99 penalty. Cancel the losing task promptly to avoid wasted generation.
Step 6: Forward cache-control and routing hints
Provider-side prompt caching cuts latency dramatically on long system prompts. If you sit behind a gateway, ensure your cache_control annotations reach the upstream. A gateway that honors client routing directives and forwards provider cache-control hints keeps your hedging logic intact without rewriting requests per provider.
client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": LONG_PROMPT, "cache_control": {"type": "ephemeral"}},
{"role": "user", "content": user_input},
],
)
If you delegate routing to a gateway, n4n.ai exposes one OpenAI-compatible endpoint covering 240+ models and applies automatic fallback when a provider is rate-limited or degraded, which reduces the failover design latency tradeoff to a config problem rather than application code.
Step 7: Measure the real failover design latency tradeoff
Instrument both paths. During steady state (primary healthy), hedging adds the cost of one extra request every time the primary exceeds your hedge delay. During incidents, sequential or hedged fallback adds the secondary’s latency minus the primary’s failure time.
Track these two numbers separately:
{
"steady_state_overhead_ms": 12,
"incident_overhead_ms": 180,
"failover_rate": 0.004
}
A 12 ms steady-state tax for a 0.4% failover rate is almost always worth it. A 180 ms incident tax is acceptable if your product SLA is 2 s. If your numbers look different, adjust the hedge delay or drop the slowest tier.
Common pitfalls
Retry storms. Leaving SDK retries on while also looping over providers produces exponential traffic to a dying upstream. Set max_retries=0 everywhere in the failover path.
Ignoring per-token metering. Hedging doubles tokens on the losing path. If your gateway does per-token usage metering, attribute those costs correctly or finance will find you. Prefer cancelling the slower stream early to stop generation, not just the HTTP call.
Breaking cache hints. Some proxies strip cache_control. Your secondary then recomputes the long system prompt from scratch, silently worsening the failover design latency tradeoff by hundreds of milliseconds.
Synchronous sequential fallback. Calling provider B only after provider A times out at 800 ms guarantees a terrible p99. Use hedging or at least a short primary timeout (200–300 ms) before racing the backup.
Treating all models as interchangeable. A fallback from a 70B-class model to an 8B model changes output quality. Encode acceptable quality tiers in fallback_order and alert when dropping below the top tier.
Reference implementation sketch
A minimal production loop looks like this:
async def complete_with_failover(messages, order, hedge_delay=0.15):
for i, target in enumerate(order):
client = get_client(target)
try:
if i == 0 and len(order) > 1:
return await hedged_call(client, get_client(order[1]), hedge_delay)
return await client.chat.completions.create(model=target["model"], messages=messages)
except (TimeoutError, ConnectionError):
continue
raise RuntimeError("all providers failed")
Start with one backup, measure, then add hedging only if the incident overhead violates your SLA. The failover design latency tradeoff is not a fixed law—it’s a dial you turn with data.