n4nAI

Debugging LangChain 429 errors with gateway-level rate limits

Step-by-step guide to diagnosing and fixing LangChain 429 error rate limits gateway issues by configuring fallback and client-side throttling.

n4n Team4 min read782 words

Audio narration

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

A langchain 429 error rate limits gateway failure shows up when your chain sends more requests than the upstream model provider allows, and the gateway in front of it returns HTTP 429 before any fallback kicks in. This guide reproduces the error with a minimal script, inspects the raw response, and walks through concrete fixes: client-side throttling, retry backoff, and gateway-aware routing.

Step 1: Reproduce the 429 with a minimal LangChain script

Point LangChain’s ChatOpenAI at your gateway endpoint. If you run a local mock or a hosted OpenAI-compatible gateway, set base_url and a dummy key. The snippet below fires 50 concurrent calls with no delay.

import asyncio
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(
    model="gpt-3.5-turbo",
    base_url="https://gateway.example.com/v1",
    api_key="sk-test",
    max_retries=0,  # disable built-in retries to see raw 429s
)

async def call_once(i: int):
    try:
        await llm.ainvoke(f"Say hello {i}")
    except Exception as e:
        print(f"Request {i} failed: {type(e).__name__}: {e}")

async def main():
    await asyncio.gather(*[call_once(i) for i in range(50)])

asyncio.run(main())

Run this against a provider with a low quota (or a gateway that enforces per-key limits) and you will see OpenAIError wrapping a 429. That is the langchain 429 error rate limits gateway pattern: the client is unaware of the server’s rate window.

For synchronous chains, the same burst happens with ThreadPoolExecutor:

from concurrent.futures import ThreadPoolExecutor
def call_sync(i):
    try:
        llm.invoke(f"Say hello {i}")
    except Exception as e:
        print(f"Request {i} failed: {e}")

with ThreadPoolExecutor(max_workers=50) as ex:
    list(ex.map(call_sync, range(50)))

The error rate scales with concurrency, not just total volume.

Step 2: Capture and inspect the raw HTTP error

LangChain swallows the response body unless you dig into the exception. Wrap the call and log the response object from the underlying openai library.

from openai import APIStatusError

async def call_and_log(i: int):
    try:
        await llm.ainvoke(f"Say hello {i}")
    except APIStatusError as e:
        body = e.response.json()
        print(f"Status {e.response.status_code}: {body}")

A typical 429 body from a gateway looks like:

{
  "error": {
    "type": "rate_limit_error",
    "message": "Rate limit reached for requests per minute: 20",
    "code": 429
  },
  "x-ratelimit-limit": "20",
  "x-ratelimit-remaining": "0",
  "retry-after": "2.5"
}

The retry-after header is your signal. If the gateway provides per-token metering, the body may also include usage details. Capture these headers; they drive the backoff in later steps.

You can also attach a LangChain callback to record every LLM call without modifying business logic:

from langchain_core.callbacks import BaseCallbackHandler

class RateLimitSniffer(BaseCallbackHandler):
    def on_llm_error(self, error, **kwargs):
        if "429" in str(error):
            print("Captured 429 in callback:", error)

llm = ChatOpenAI(..., callbacks=[RateLimitSniffer()])

Step 3: Configure LangChain retry with exponential backoff

LangChain’s ChatOpenAI delegates to the OpenAI SDK, which respects max_retries. The default is two retries with a short fixed delay. That bursts retries immediately after a 429 and can extend the saturation window. Use tenacity to wrap your own logic instead.

from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from openai import RateLimitError

@retry(
    retry=retry_if_exception_type(RateLimitError),
    wait=wait_exponential(multiplier=1, min=2, max=30),
    stop=stop_after_attempt(5),
)
async def safe_invoke(prompt: str):
    return await llm.ainvoke(prompt)

For synchronous code, the same decorator works on llm.invoke. The key change is the exponential wait: first retry waits ~2s, then 4s, then 8s, giving the gateway’s limit window time to reset.

This reduces immediate retries but does not solve concurrency: 50 tasks still hit the gateway simultaneously. You need throttling before the network call.

Step 4: Add client-side rate limiting

Use an asyncio.Semaphore to cap concurrent requests, and a simple token bucket for per-minute rates. Below is a minimal bucket that reads the limit from the first 429.

import time, asyncio

class TokenBucket:
    def __init__(self, rate: int, per: float):
        self.rate = rate
        self.per = per
        self.tokens = rate
        self.last = time.monotonic()
        self.lock = asyncio.Lock()

    async def acquire(self):
        async with self.lock:
            while True:
                now = time.monotonic()
                elapsed = now - self.last
                self.tokens = min(self.rate, self.tokens + elapsed * (self.rate / self.per))
                if self.tokens >= 1:
                    self.tokens -= 1
                    self.last = now
                    return
                await asyncio.sleep(0.1)

bucket = TokenBucket(rate=20, per=60)  # match gateway limit

async def throttled_call(i: int):
    await bucket.acquire()
    return await safe_invoke(f"Say hello {i}")

Now run 50 calls; they will be spaced to 20 per minute. The langchain 429 error rate limits gateway loop disappears because the client respects the window.

In a threaded sync context, use threading.Semaphore plus a blocking token bucket:

import threading, time

class SyncTokenBucket:
    def __init__(self, rate, per):
        self.rate = rate; self.per = per
        self.tokens = rate; self.last = time.monotonic()
        self.lock = threading.Lock()
    def acquire(self):
        with self.lock:
            while True:
                now = time.monotonic()
                self.tokens = min(self.rate, self.tokens + (now-self.last)*(self.rate/self.per))
                if self.tokens >= 1:
                    self.tokens -= 1; self.last = now; return
                time.sleep(0.05)

Step 5: Leverage gateway-level fallback and routing

A gateway that fronts multiple providers can shield you from single-provider limits. For example, n4n.ai exposes one OpenAI-compatible endpoint across 240+ models and automatically fails over when a provider is rate-limited or degraded. Even with that, the first request may still get a 429 if all backing providers are saturated.

You can send client routing directives via extra_headers so the gateway prefers a less busy provider:

llm = ChatOpenAI(
    model="auto",
    base_url="https://gateway.example.com/v1",
    api_key="sk-test",
    extra_headers={"x-prefer-provider": "anthropic"},  # gateway honors directive
)

If the gateway forwards provider cache-control hints, add them to cut token cost on repeated prompts:

extra_body = {"cache_control": {"type": "ephemeral"}}
await llm.ainvoke("Long system context...", extra_body=extra_body)

The gateway passes the hint to the provider, reducing repeated input tokens and indirectly lowering rate-limit pressure. Do not assume fallback is instant; design your client to tolerate a 429 even when a gateway promises automatic failover.

Step 6: Track per-token usage to anticipate limits

Rate limits are often token-based, not request-based. Read usage from the LangChain result and maintain a rolling sum.

response = await llm.ainvoke("Explain rate limits")
usage = response.usage_metadata  # {'input_tokens': 10, 'output_tokens': 20}

If your gateway provides per-token metering, log these to detect when you approach the token ceiling. Switch models or batch prompts when the sum exceeds 80% of the known quota. A simple rolling window:

from collections import deque
import time

class TokenWindow:
    def __init__(self, limit, seconds):
        self.limit = limit; self.seconds = seconds
        self.q = deque()
    def add(self, tokens):
        now = time.time()
        self.q.append((now, tokens))
        while self.q and now - self.q[0][0] > self.seconds:
            self.q.popleft()
    @property
    def used(self):
        return sum(t for _, t in self.q)

Check used < limit*0.8 before sending a large batch.

Step 7: Verify success with a controlled load test

Replace the dummy gateway with your real endpoint and run the throttled client under expected peak load. Success criteria:

  • Zero RateLimitError exceptions over a 5-minute window.
  • x-ratelimit-remaining stays above zero in logged headers.
  • End-to-end latency per call stays under your SLA despite backoff.

A quick verification script:

async def verify():
    tasks = [throttled_call(i) for i in range(100)]
    results = await asyncio.gather(*tasks, return_exceptions=True)
    errors = [r for r in results if isinstance(r, Exception)]
    print(f"Errors: {len(errors)} / 100")

asyncio.run(verify())

If Errors: 0 / 100 prints, your langchain 429 error rate limits gateway handling is correct.

Common pitfalls

  • Setting max_retries too high: The OpenAI SDK will block the event loop or thread pool, masking the real throughput problem.
  • Ignoring retry-after: A static exponential backoff may wait longer or shorter than the gateway needs; parse the header when present.
  • Routing headers without gateway support: If the gateway does not honor the directive, the header is silent dead weight.
  • Mixing sync and async throttles: An asyncio.Semaphore does nothing in a thread pool; use the right primitive for the execution model.

Following these steps moves the rate-limit concern from reactive exception handling to proactive client/gateway coordination.

Tagslangchainrate-limitsdebugginggateway

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 langchain + openai-compatible gateway integration posts →