Rate limits on GPT-5 will bite you in production. A fallback strategy GPT-5 rate limits approach isn’t optional — it’s the difference between a degraded experience and a hard outage. This guide walks through building a production-grade fallback chain: detect the limit, retry with backoff, route to a cheaper model, and surface the right signals to your observability stack.
Step 1: Classify the error correctly
Not every 429 is a rate limit. Providers return 429 for quota exhaustion, concurrent request limits, and per-model capacity constraints. The response body tells you which one you hit.
import httpx
from enum import Enum
from dataclasses import dataclass
class RateLimitType(Enum):
TOKENS_PER_MINUTE = "tokens_per_minute"
REQUESTS_PER_MINUTE = "requests_per_minute"
CONCURRENT_REQUESTS = "concurrent_requests"
QUOTA_EXHAUSTED = "quota_exhausted"
MODEL_CAPACITY = "model_capacity"
UNKNOWN = "unknown"
@dataclass
class RateLimitError(Exception):
limit_type: RateLimitType
retry_after_seconds: float | None
provider: str
model: str
raw_response: dict
def classify_rate_limit(response: httpx.Response) -> RateLimitError:
data = response.json()
error = data.get("error", {})
message = error.get("message", "").lower()
code = error.get("code", "")
if "tokens per minute" in message or code == "rate_limit_exceeded_tpm":
limit_type = RateLimitType.TOKENS_PER_MINUTE
elif "requests per minute" in message or code == "rate_limit_exceeded_rpm":
limit_type = RateLimitType.REQUESTS_PER_MINUTE
elif "concurrent" in message or code == "too_many_requests":
limit_type = RateLimitType.CONCURRENT_REQUESTS
elif "quota" in message or "billing" in message:
limit_type = RateLimitType.QUOTA_EXHAUSTED
elif "capacity" in message or "unavailable" in message:
limit_type = RateLimitType.MODEL_CAPACITY
else:
limit_type = RateLimitType.UNKNOWN
retry_after = None
if "retry-after" in response.headers:
retry_after = float(response.headers["retry-after"])
elif "retry_after" in error:
retry_after = float(error["retry_after"])
return RateLimitError(
limit_type=limit_type,
retry_after_seconds=retry_after,
provider="openai",
model="gpt-5",
raw_response=data
)
Verify: unit test each classification path against real provider response fixtures. Check that retry_after_seconds parses correctly from both headers and body.
Step 2: Implement exponential backoff with jitter
A naive retry loop hammers the endpoint the moment capacity returns. Use decorrelated jitter to spread load.
import asyncio
import random
import time
from typing import Callable, TypeVar, Awaitable
T = TypeVar("T")
async def retry_with_backoff(
fn: Callable[[], Awaitable[T]],
*,
max_attempts: int = 5,
base_delay: float = 1.0,
max_delay: float = 60.0,
retryable_exceptions: tuple[type[Exception], ...] = (RateLimitError,),
) -> T:
attempt = 0
delay = base_delay
while True:
try:
return await fn()
except retryable_exceptions as e:
attempt += 1
if attempt >= max_attempts:
raise
if e.retry_after_seconds is not None:
sleep_time = min(e.retry_after_seconds, max_delay)
else:
sleep_time = min(delay, max_delay)
jitter = random.uniform(0, sleep_time * 0.1)
await asyncio.sleep(sleep_time + jitter)
delay = min(delay * 2, max_delay)
Verify: simulate a flaky endpoint that fails 3 times then succeeds. Confirm total latency stays under your SLA budget and attempts don’t exceed max_attempts.
Step 3: Define your fallback model chain
GPT-5 rate limits mean you need alternatives ready. Rank fallbacks by capability match, not just price. A reasoning task needs a reasoning model; a classification task can tolerate a smaller model.
from dataclasses import dataclass
from typing import Literal
ModelTier = Literal["primary", "fallback_1", "fallback_2", "fallback_3"]
@dataclass(frozen=True)
class FallbackModel:
model_id: str
provider: str
tier: ModelTier
max_tokens: int
supports_reasoning: bool
cost_per_1k_tokens: float
FALLBACK_CHAIN: list[FallbackModel] = [
FallbackModel(
model_id="gpt-5",
provider="openai",
tier="primary",
max_tokens=128000,
supports_reasoning=True,
cost_per_1k_tokens=0.015
),
FallbackModel(
model_id="gpt-5-mini",
provider="openai",
tier="fallback_1",
max_tokens=128000,
supports_reasoning=True,
cost_per_1k_tokens=0.003
),
FallbackModel(
model_id="claude-3-5-sonnet-20241022",
provider="anthropic",
tier="fallback_2",
max_tokens=8192,
supports_reasoning=True,
cost_per_1k_tokens=0.003
),
FallbackModel(
model_id="gpt-4o-mini",
provider="openai",
tier="fallback_3",
max_tokens=16384,
supports_reasoning=False,
cost_per_1k_tokens=0.00015
),
]
def select_fallback(
failed_model: str,
requires_reasoning: bool,
max_tokens_needed: int
) -> FallbackModel | None:
for model in FALLBACK_CHAIN:
if model.model_id == failed_model:
continue
if model.max_tokens < max_tokens_needed:
continue
if requires_reasoning and not model.supports_reasoning:
continue
return model
return None
Verify: integration test the selector with every combination of requires_reasoning and max_tokens_needed. Confirm it never returns a model that can’t handle the request.
Step 4: Build the routing client with provider abstraction
Your calling code shouldn’t know which provider it’s hitting. Wrap each provider’s SDK behind a common interface.
from abc import ABC, abstractmethod
from typing import AsyncIterator
@dataclass
class ChatMessage:
role: Literal["system", "user", "assistant", "tool"]
content: str
tool_calls: list[dict] | None = None
@dataclass
class ChatResponse:
content: str
model: str
provider: str
usage: dict
finish_reason: str
class ProviderClient(ABC):
@abstractmethod
async def chat(
self,
messages: list[ChatMessage],
model: str,
max_tokens: int,
temperature: float,
stream: bool = False,
) -> ChatResponse | AsyncIterator[ChatResponse]:
pass
class OpenAIClient(ProviderClient):
def __init__(self, api_key: str, base_url: str | None = None):
self.client = AsyncOpenAI(api_key=api_key, base_url=base_url)
async def chat(self, messages, model, max_tokens, temperature, stream=False):
response = await self.client.chat.completions.create(
model=model,
messages=[m.__dict__ for m in messages],
max_tokens=max_tokens,
temperature=temperature,
stream=stream,
)
if stream:
return self._stream_response(response, model)
return ChatResponse(
content=response.choices[0].message.content or "",
model=response.model,
provider="openai",
usage=response.usage.model_dump() if response.usage else {},
finish_reason=response.choices[0].finish_reason or "stop",
)
class AnthropicClient(ProviderClient):
def __init__(self, api_key: str):
self.client = AsyncAnthropic(api_key=api_key)
async def chat(self, messages, model, max_tokens, temperature, stream=False):
system = next((m.content for m in messages if m.role == "system"), None)
user_messages = [m for m in messages if m.role != "system"]
response = await self.client.messages.create(
model=model,
system=system,
messages=[{"role": m.role, "content": m.content} for m in user_messages],
max_tokens=max_tokens,
temperature=temperature,
stream=stream,
)
if stream:
return self._stream_response(response, model)
return ChatResponse(
content=response.content[0].text if response.content else "",
model=response.model,
provider="anthropic",
usage={"input_tokens": response.usage.input_tokens, "output_tokens": response.usage.output_tokens},
finish_reason=response.stop_reason or "end_turn",
)
PROVIDER_CLIENTS: dict[str, ProviderClient] = {
"openai": OpenAIClient(api_key=os.getenv("OPENAI_API_KEY")),
"anthropic": AnthropicClient(api_key=os.getenv("ANTHROPIC_API_KEY")),
}
Verify: run contract tests against each provider’s sandbox environment. Confirm ChatResponse fields populate correctly for both streaming and non-streaming modes.
Step 5: Wire the fallback loop
Now compose classification, backoff, selection, and routing into a single call path.
@dataclass
class FallbackResult:
response: ChatResponse
fallback_tier: ModelTier
attempts: int
total_latency_ms: float
rate_limited: bool
class FallbackRouter:
def __init__(
self,
provider_clients: dict[str, ProviderClient],
fallback_chain: list[FallbackModel],
max_attempts_per_model: int = 3,
):
self.clients = provider_clients
self.chain = fallback_chain
self.max_attempts = max_attempts_per_model
async def chat_with_fallback(
self,
messages: list[ChatMessage],
requires_reasoning: bool = False,
max_tokens: int = 4096,
temperature: float = 0.7,
) -> FallbackResult:
start_time = time.perf_counter()
primary_model = self.chain[0]
current_model = primary_model
total_attempts = 0
rate_limited = False
while current_model:
client = self.clients.get(current_model.provider)
if not client:
current_model = self._next_fallback(current_model, requires_reasoning, max_tokens)
continue
for attempt in range(self.max_attempts):
total_attempts += 1
try:
response = await client.chat(
messages=messages,
model=current_model.model_id,
max_tokens=max_tokens,
temperature=temperature,
)
latency_ms = (time.perf_counter() - start_time) * 1000
return FallbackResult(
response=response,
fallback_tier=current_model.tier,
attempts=total_attempts,
total_latency_ms=latency_ms,
rate_limited=rate_limited,
)
except RateLimitError as e:
rate_limited = True
if attempt == self.max_attempts - 1:
break
await retry_with_backoff(lambda: asyncio.sleep(0), max_attempts=1)
except Exception:
if attempt == self.max_attempts - 1:
raise
current_model = self._next_fallback(current_model, requires_reasoning, max_tokens)
raise RuntimeError("All fallback models exhausted")
def _next_fallback(
self,
failed_model: FallbackModel,
requires_reasoning: bool,
max_tokens_needed: int
) -> FallbackModel | None:
return select_fallback(failed_model.model_id, requires_reasoning, max_tokens_needed)
Verify: chaos test by injecting rate limit errors at each tier. Confirm the router progresses through the chain and returns a FallbackResult with correct fallback_tier and attempts count.
Step 6: Emit structured observability
You can’t improve what you don’t measure. Log every fallback event with enough context to debug and alert.
import structlog
from contextvars import ContextVar
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
logger = structlog.get_logger()
class ObservabilityMiddleware:
def __init__(self, router: FallbackRouter):
self.router = router
async def chat_with_fallback(self, *args, **kwargs) -> FallbackResult:
request_id = request_id_var.get()
start = time.perf_counter()
try:
result = await self.router.chat_with_fallback(*args, **kwargs)
latency_ms = (time.perf_counter() - start) * 1000
logger.info(
"fallback_chat_completed",
request_id=request_id,
primary_model="gpt-5",
fallback_model=result.response.model,
fallback_provider=result.response.provider,
fallback_tier=result.fallback_tier.value,
attempts=result.attempts,
total_latency_ms=latency_ms,
rate_limited=result.rate_limited,
prompt_tokens=result.response.usage.get("prompt_tokens", 0),
completion_tokens=result.response.usage.get("completion_tokens", 0),
)
return result
except Exception as e:
latency_ms = (time.perf_counter() - start) * 1000
logger.error(
"fallback_chat_failed",
request_id=request_id,
error_type=type(e).__name__,
error_message=str(e),
total_latency_ms=latency_ms,
)
raise
Verify: check your log aggregation (Datadog, Loki, etc.) for fallback_chat_completed events. Build a dashboard showing fallback tier distribution, latency by tier, and rate limit frequency over time.
Step 7: Add client-side routing hints
Let callers express preferences without hardcoding model IDs. Pass directives through headers or request fields.
from typing import TypedDict, NotRequired
class RoutingDirectives(TypedDict):
prefer_provider: NotRequired[str]
avoid_providers: NotRequired[list[str]]
max_cost_per_1k: NotRequired[float]
require_reasoning: NotRequired[bool]
max_latency_ms: NotRequired[int]
def apply_directives(
chain: list[FallbackModel],
directives: RoutingDirectives
) -> list[FallbackModel]:
filtered = chain
if "prefer_provider" in directives:
preferred = [m for m in filtered if m.provider == directives["prefer_provider"]]
others = [m for m in filtered if m.provider != directives["prefer_provider"]]
filtered = preferred + others
if "avoid_providers" in directives:
filtered = [m for m in filtered if m.provider not in directives["avoid_providers"]]
if "max_cost_per_1k" in directives:
filtered = [m for m in filtered if m.cost_per_1k_tokens <= directives["max_cost_per_1k"]]
if directives.get("require_reasoning"):
filtered = [m for m in filtered if m.supports_reasoning]
return filtered
Verify: integration test with various directive combinations. Confirm the filtered chain respects all constraints and falls back gracefully when directives eliminate all options.
Step 8: Handle streaming fallbacks gracefully
Streaming adds complexity — you can’t switch models mid-stream. Decide upfront or buffer and restart.
async def chat_with_fallback_streaming(
self,
messages: list[ChatMessage],
requires_reasoning: bool = False,
max_tokens: int = 4096,
temperature: float = 0.7,
) -> AsyncIterator[ChatResponse]:
current_model = self.chain[0]
while current_model:
client = self.clients.get(current_model.provider)
if not client:
current_model = self._next_fallback(current_model, requires_reasoning, max_tokens)
continue
try:
async for chunk in await client.chat(
messages=messages,
model=current_model.model_id,
max_tokens=max_tokens,
temperature=temperature,
stream=True,
):
yield chunk
return
except RateLimitError:
current_model = self._next_fallback(current_model, requires_reasoning, max_tokens)
continue
except Exception:
current_model = self._next_fallback(current_model, requires_reasoning, max_tokens)
continue
raise RuntimeError("All fallback models exhausted for streaming")
Verify: test streaming fallback by rate-limiting the primary model after 3 chunks. Confirm the client receives a clean stream from the fallback model without duplicate or missing tokens.
Step 9: Configure per-environment limits
Development, staging, and production need different fallback behaviors. Use feature flags, not code changes.
from pydantic import BaseModel
from pydantic_settings import BaseSettings
class FallbackConfig(BaseModel):
enabled: bool = True
max_attempts_per_model: int = 3
base_delay_seconds: float = 1.0
max_delay_seconds: float = 60.0
allow_cross_provider_fallback: bool = True
emit_fallback_metrics: bool = True
class Settings(BaseSettings):
fallback: FallbackConfig = FallbackConfig()
class Config:
env_prefix = "N4N_"
env_nested_delimiter = "__"
settings = Settings()
router = FallbackRouter(
provider_clients=PROVIDER_CLIENTS,
fallback_chain=FALLBACK_CHAIN,
max_attempts_per_model=settings.fallback.max_attempts_per_model,
)
Verify: deploy to staging with max_attempts_per_model=1 and allow_cross_provider_fallback=false. Confirm the router fails fast and doesn’t cross provider boundaries.
Step 10: Load test the full chain
Synthetic load reveals bottlenecks your unit tests miss. Run a sustained ramp that triggers every fallback tier.
# locustfile.py
from locust import HttpUser, task, between
import json
class FallbackLoadTest(HttpUser):
wait_time = between(0.1, 0.5)
@task
def chat_with_fallback(self):
payload = {
"messages": [{"role": "user", "content": "Explain quantum computing in 3 sentences"}],
"requires_reasoning": True,
"max_tokens": 500,
}
headers = {"Content-Type": "application/json"}
with self.client.post("/v1/chat/fallback", json=payload, headers=headers, catch_response=True) as response:
if response.status_code == 200:
data = response.json()
if data.get("fallback_tier") != "primary":
response.failure(f"Fell back to {data['fallback_tier']}")
else:
response.failure(f"HTTP {response.status_code}")
Run: locust -f locustfile.py --host=https://staging.yourapi.com --users=50 --spawn-rate=5 --run-time=5m
Verify: watch the fallback tier distribution in your dashboard. Primary should handle >95% of requests under normal load. Under induced rate limits, fallback_1 should absorb traffic without error rate spikes. No requests should reach fallback_3 unless you’ve simulated a multi-provider outage.
What to monitor in production
- Fallback rate by tier: Alert if fallback_1 exceeds 5% of traffic for 5 minutes
- Latency delta: Fallback models add latency; track p99 by tier
- Error budget burn: Rate limit errors that exhaust all fallbacks consume error budget fast
- Cost per request: Fallback to cheaper models saves money; fallback to expensive cross-provider models costs more
- Reasoning degradation: If
requires_reasoning=truerequests hit non-reasoning fallbacks, log a warning
A fallback strategy GPT-5 rate limits implementation is never done. Providers change limits, new models launch, and your traffic patterns shift. Treat the fallback chain as living infrastructure — version it, test it, and observe it like any other critical path.