n4nAI

Client-side rate limiting to avoid 429 errors

Step-by-step guide to implement client-side rate limiting avoid 429 errors when calling LLM APIs, with Python and TypeScript code examples for production.

n4n Team3 min read695 words

Audio narration

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

A 429 from an LLM provider means you’ve blown past a quota, but retrying harder just digs the hole deeper. Implementing client-side rate limiting avoid 429 errors at the application layer lets you shape traffic before it leaves your process, cutting wasted requests and tail latency. This guide gives you a token bucket, backoff logic, and a verification loop you can ship in Python or TypeScript today.

Step 1: Read the limit headers, not just the docs

Providers publish RPM (requests per minute) and TPM (tokens per minute), but the enforced values shift with tier and load. The authoritative signal is in response headers: x-ratelimit-limit-requests, x-ratelimit-remaining-requests, and retry-after on a 429. Parse them on every call so your limiter tracks reality, not a guess.

import httpx

def extract_limits(headers: dict) -> tuple[int, int] | None:
    limit = headers.get("x-ratelimit-limit-requests")
    remaining = headers.get("x-ratelimit-remaining-requests")
    if limit and remaining:
        return int(limit), int(remaining)
    return None

If you call an OpenAI-compatible gateway, the same headers propagate. Use them to seed your bucket capacity instead of hardcoding from a stale wiki page.

Step 2: Implement a token bucket

The token bucket is the right primitive: it allows bursts up to capacity and refills at a steady rate. Below is a minimal async Python version using time.monotonic and asyncio.

import asyncio
import time

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

    async def acquire(self, needed: float = 1.0):
        async with self.lock:
            while True:
                now = time.monotonic()
                self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
                self.last = now
                if self.tokens >= needed:
                    self.tokens -= needed
                    return
                wait = (needed - self.tokens) / self.rate
                await asyncio.sleep(wait)

TypeScript mirrors this with Date.now() and a promise-based sleep:

export class TokenBucket {
  private tokens: number;
  private last = Date.now();
  constructor(private rate: number, private capacity: number) {
    this.tokens = capacity;
  }
  async acquire(needed = 1): Promise<void> {
    while (true) {
      const now = Date.now();
      this.tokens = Math.min(this.capacity, this.tokens + ((now - this.last) / 1000) * this.rate);
      this.last = now;
      if (this.tokens >= needed) {
        this.tokens -= needed;
        return;
      }
      const waitMs = ((needed - this.tokens) / this.rate) * 1000;
      await new Promise(r => setTimeout(r, waitMs));
    }
  }
}

Set rate = rpm / 60 and capacity = burst (e.g., 10% of RPM). Client-side rate limiting avoid 429 by never issuing more than the bucket allows, even when your upstream queue spikes.

Step 3: Wrap your LLM call

Inject the bucket into the request path. In Python, wrap httpx.AsyncClient or an OpenAI SDK client. Here we use raw httpx against an OpenAI-compatible endpoint:

async def chat(bucket: TokenBucket, payload: dict, client: httpx.AsyncClient):
    await bucket.acquire(1)  # 1 request token; add TPM tracking separately
    resp = await client.post("/v1/chat/completions", json=payload)
    if resp.status_code == 429:
        # handled in step 4
        pass
    return resp

For TypeScript with fetch:

async function chat(bucket: TokenBucket, payload: any): Promise<Response> {
  await bucket.acquire(1);
  return fetch("/v1/chat/completions", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(payload),
  });
}

If you route through a gateway such as n4n.ai, automatic fallback covers provider-side degradation, but your loop still needs client-side rate limiting avoid 429 from your own over-issuance.

Step 4: Back off on 429 with jitter

A 429 means the bucket estimate was wrong or a shared key got throttled. Read retry-after (seconds) and add full jitter to avoid thundering herd.

import random
import asyncio

async def call_with_backoff(bucket, payload, client, max_retries=5):
    for attempt in range(max_retries):
        resp = await chat(bucket, payload, client)
        if resp.status_code != 429:
            return resp
        retry_after = float(resp.headers.get("retry-after", 1))
        sleep = retry_after * (0.5 + random.random())  # jitter
        bucket.rate *= 0.5  # shrink rate temporarily
        await asyncio.sleep(sleep)
    raise RuntimeError("exhausted retries on 429")

The rate halving is critical: blind retry ignores the cause. Client-side rate limiting avoid 429 by adapting the refill rate downward when rejected, then recovering slowly after a cool-down.

Step 5: Track token usage, not just request count

Request limits are easy; token-per-minute limits bite harder on long prompts. Extend the bucket to deduct based on usage.total_tokens if your provider returns it. Most OpenAI-compatible responses include a usage JSON object.

if resp.status_code == 200:
    used = resp.json().get("usage", {}).get("total_tokens", 0)
    # log used; for TPM use a separate bucket acquired before send

Implement a second bucket for TPM with rate = tpm / 60 and acquire used / 1000 before sending. This dual-bucket pattern is what keeps you under both ceilings when prompt sizes vary.

Step 6: Verify with a controlled load test

You can’t claim success without evidence. Write a script that fires at 2x your assumed limit and assert zero 429s in the log.

python -m locust -f load_test.py --headless -u 200 -r 20 -t 2m

In load_test.py, reuse your production client wrapped with the bucket. After the run, check:

  • Application logs show no 429 responses.
  • p99 latency stays under your SLO (e.g., 2s).
  • Bucket rate never exceeded the provider’s published RPM when averaged over a minute.

If you see 429s, your header parsing in Step 1 missed a quota type. Fix the seed, not the sleep.

Step 7: Emit metrics and alert

A limiter silent in dashboards is a limiter that drifts. Export tokens_waiting, rate_current, and 429_count to Prometheus. Alert if 429_count > 0 for five minutes—that means your client-side rate limiting avoid 429 strategy needs retuning because the provider changed limits.

from prometheus_client import Gauge, Counter
TOKENS_WAITING = Gauge("bucket_waiting", "tokens blocked")
RATE = Gauge("bucket_rate", "current token rate")
FOUR29 = Counter("http_429_total", "count of 429s")

Wire these inside acquire and the backoff path so you can see throttling before users do.

Step 8: Handle cache-control to skip redundant calls

Some gateways forward provider cache-control hints. If your request hits a cached completion, you avoid both a token charge and a rate-limit deduction. Honor cache-control: max-age by skipping the bucket acquire for identical payloads within the window. This is a force multiplier for client-side rate limiting avoid 429 because it removes the call entirely.

if cached_response and not expired:
    return cached_response
await bucket.acquire(1)

Gateways like n4n.ai honor client routing directives and forward provider cache-control hints, so a local cache keyed on prompt hash compounds the savings without extra round trips.

Verify success

Success means: under sustained 2x expected load, your service logs zero 429s, p99 latency is stable, and the token bucket’s rate metric tracks the provider’s documented RPM within 5%. Re-run the load test weekly; providers tweak limits quietly.

That’s the whole loop. Ship the bucket, wrap the client, back off with jitter, and measure.

Tagsrate-limitsclient-side429-errorsbest-practices

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 rate limits, retries & backoff strategies posts →