n4nAI

Rate limits and 429s: building agents that route around them

Build resilient LLM agents that detect 429s and reroute requests. Step-by-step guide to implementing rate limit routing agents with fallback logic.

n4n Team3 min read595 words

Audio narration

Coming soon — every post will get a voice note here.

A 429 from an LLM provider can stall an agent mid-task. Building rate limit routing agents that detect throttling and shift traffic to healthy endpoints is the difference between a demo and a production system.

Most teams start with a single openai call wrapped in a retry loop. That breaks the moment your primary provider enforces a per-minute token quota or a concurrent request cap. This guide walks through a concrete fallback chain, backoff strategy, and circuit breaker you can ship today.

Why naive retries fail

A bare time.sleep(1) retry ignores the Retry-After header and treats all providers as equal. If the same model is saturated across regions, you burn latency and still fail. Rate limit routing agents need to inspect the failure, parse headers, and switch models or providers without blocking the agent loop.

Step 1: Capture the 429 and its metadata

OpenAI-compatible APIs return a 429 with a Retry-After or x-ratelimit-reset header. Catch the exception and extract these fields before deciding what to do.

from openai import OpenAI, APIStatusError
import os

def call_model(base_url, api_key, model, messages):
    client = OpenAI(base_url=base_url, api_key=api_key)
    try:
        resp = client.chat.completions.create(model=model, messages=messages)
        return resp
    except APIStatusError as e:
        if e.status_code == 429:
            retry_after = e.response.headers.get("Retry-After")
            reset = e.response.headers.get("x-ratelimit-reset")
            raise RateLimitHit(retry_after, reset)
        raise

class RateLimitHit(Exception):
    def __init__(self, retry_after, reset):
        self.retry_after = int(retry_after) if retry_after else None
        self.reset = reset
        super().__init__("429")

Log the model, base URL, and reset time. That data feeds the routing decision in later steps.

Step 2: Build a provider fallback chain

Define an ordered list of targets. Each target is an OpenAI-compatible endpoint and model. A gateway such as n4n.ai exposes one OpenAI-compatible endpoint that addresses 240+ models and applies automatic fallback when a provider is rate-limited or degraded, but you can also manage the chain yourself.

PROVIDERS = [
    {"base_url": "https://api.openai.com/v1", "api_key": os.environ["OAI"], "model": "gpt-4o-mini"},
    {"base_url": "https://api.anthropic.com/v1", "api_key": os.environ["ANT"], "model": "claude-3-haiku"},
    {"base_url": "https://gateway.n4n.ai/v1", "api_key": os.environ["N4N"], "model": "meta-llama/llama-3.1-8b"},
]

The router iterates the list, skipping providers that are circuit-open (see Step 4).

def route(messages, providers):
    last_err = None
    for p in providers:
        if breaker[p["base_url"]].is_open():
            continue
        try:
            return call_model(p["base_url"], p["api_key"], p["model"], messages)
        except RateLimitHit as e:
            breaker[p["base_url"]].trip(e.retry_after)
            last_err = e
    raise last_err or RuntimeError("no providers")

Step 3: Implement exponential backoff with jitter

When a provider returns a Retry-After, honor it. For unspecified 429s, use capped exponential backoff. Never busy-loop.

import random, time

def backoff(attempt, explicit=None):
    if explicit:
        time.sleep(min(explicit, 30))
        return
    sleep = min(2 ** attempt + random.uniform(0, 0.5), 30)
    time.sleep(sleep)

Wrap the route call in a loop with a max attempt budget per agent step:

def agent_step(messages, max_attempts=4):
    for attempt in range(max_attempts):
        try:
            return route(messages, PROVIDERS)
        except RateLimitHit as e:
            backoff(attempt, e.retry_after)
    raise RuntimeError("exhausted routing attempts")

Step 4: Add a circuit breaker to skip degraded providers

A provider that 429s repeatedly should be avoided for a short window. An in-memory breaker keyed by base URL works for single-process agents; use Redis for distributed workers.

from collections import defaultdict

class Breaker:
    def __init__(self, cooldown=60):
        self.cooldown = cooldown
        self.open_until = 0
    def trip(self, retry_after):
        self.open_until = time.time() + (retry_after or self.cooldown)
    def is_open(self):
        return time.time() < self.open_until

breaker = defaultdict(Breaker)

This prevents your rate limit routing agents from hammering a dead endpoint while other providers absorb load.

Step 5: Honor routing directives and cache hints

Production agents often need to pin a model for consistency or leverage provider prompt caching. If you send cache_control hints, ensure your client forwards them. Some gateways honor client routing directives and forward provider cache-control hints, so a single request can specify model: claude-3-sonnet and still get cached prefix handling without custom middleware.

When self-managing, pass extra headers explicitly:

client = OpenAI(base_url=base_url, api_key=api_key, default_headers={
    "x-cache-control": "ttl=300"
})

Verify your target provider actually supports the hint; otherwise it is silently ignored.

Step 6: Meter usage and observe

Per-token usage metering is non-negotiable for cost control. Capture usage from each response and tag it with the provider that served it.

def log_usage(resp, provider):
    u = resp.usage
    metrics.record({
        "provider": provider["base_url"],
        "model": resp.model,
        "prompt_tokens": u.prompt_tokens,
        "completion_tokens": u.completion_tokens,
    })

If you use a gateway with per-token usage metering, aggregate from its response headers or dashboard instead of instrumenting each branch.

Verify your agent routes around limits

You cannot trust fallback logic until you force a 429. Stand up a local mock that returns 429 for the first three calls, then 200.

# test_mock.py
from fastapi import FastAPI, Response
import pytest

app = FastAPI()
calls = {"n": 0}

@app.post("/v1/chat/completions")
async def mock(resp: Response):
    calls["n"] += 1
    if calls["n"] <= 3:
        resp.status_code = 429
        resp.headers["Retry-After"] = "0"
        return {"error": "rate limited"}
    return {"model": "mock", "choices": [{"message": {"content": "ok"}}],
            "usage": {"prompt_tokens": 1, "completion_tokens": 1}}

Point PROVIDERS[0] at this mock and assert agent_step returns a valid response after the limit clears. Run:

pytest test_mock.py -q

If the test passes, your rate limit routing agents correctly detect the 429, back off, and fall through to the next provider. In production, watch the breaker trip counts and token metrics; a healthy system shows occasional trips but zero agent-step failures.

Closing notes

Routing around 429s is not glamorous, but it is the core reliability work for agentic apps. Capture the headers, chain providers, break circuits, and meter everything. Do that and your agents will survive the next provider outage without a page at 3 a.m.

Tagsrate-limitsllm-routingfallbackai-agents

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All llm routing & fallback for agentic apps posts →