Jun 23, 2026 · 7 min · API

Handling Rate Limits and Retries on LLM APIs

Handling Rate Limits and Retries on LLM APIs

At 09:12 on a Tuesday, our summarization pipeline looked healthy: 96 workers online, median model latency around 2.8 seconds, no elevated 5xx rate. Then a customer uploaded a backlog of 38,000 support tickets.

Within four minutes, the pipeline was “down” without any server being down. We were hitting two different limits at once:

The naive retry logic made it worse. Every worker saw 429, slept for one second, and retried together. That created a retry wave, which produced more 429s, which created another wave. The system had accidentally built a synchronized denial-of-service attack against its own quota.

Handling LLM rate limits is not just “add exponential backoff.” In production, you need to reason about tokens, queues, concurrency, retries, idempotency, fallback providers, and billing behavior as one system.

The Two Limits That Matter: RPM and TPM

Most LLM APIs enforce some combination of:

The important part: RPM and TPM fail differently.

If you send many tiny prompts, you hit RPM first. If you send fewer large prompts, you hit TPM first.

Example:

Limit:
- 600 requests/minute
- 300,000 tokens/minute

Workload A:
- 1,000 short classification calls/minute
- 200 tokens each
- 200,000 tokens/minute total

Result:
- TPM is fine
- RPM fails

Workload B:
- 100 long document analysis calls/minute
- 5,000 tokens each
- 500,000 tokens/minute total

Result:
- RPM is fine
- TPM fails

A common gotcha: developers throttle requests but not tokens. That works in staging, where prompts are short. Then production users paste PDFs, chat histories, logs, or legal contracts, and the token budget evaporates.

In practice, I treat every LLM call as reserving two resources:

cost = {
  requests: 1,
  tokens: estimated_prompt_tokens + max_output_tokens
}

Even if the model returns fewer output tokens than max_output_tokens, reserving the upper bound prevents oversubscription.

What Actually Happens When You Ignore TPM

Suppose you have:

TPM limit: 120,000
Average request: 4,000 input tokens + 1,000 max output tokens
Reserved cost: 5,000 tokens

Your real maximum throughput is not “as many workers as possible.” It is:

120,000 / 5,000 = 24 requests per minute

That is one request every 2.5 seconds on average.

If you run 100 workers, the first batch may all start successfully if the provider allows burstiness. Then the account hits a hard wall. Every subsequent request gets 429, and retries pile up.

The fix is not just lowering worker count. You need a scheduler that understands token reservations.

A Minimal Token-Aware Rate Limiter

For a single process, a token bucket is usually enough. You refill capacity over time and require each request to acquire both a request slot and a token slot.

Here is a simplified Python example:

import asyncio
import time
from dataclasses import dataclass

@dataclass
class Bucket:
    capacity: float
    refill_per_second: float
    tokens: float
    updated_at: float

    def refill(self):
        now = time.monotonic()
        elapsed = now - self.updated_at
        self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_per_second)
        self.updated_at = now

    async def acquire(self, amount: float):
        while True:
            self.refill()
            if self.tokens >= amount:
                self.tokens -= amount
                return

            missing = amount - self.tokens
            sleep_for = missing / self.refill_per_second
            await asyncio.sleep(min(sleep_for, 1.0))


class LLMRateLimiter:
    def __init__(self, rpm: int, tpm: int):
        now = time.monotonic()
        self.request_bucket = Bucket(
            capacity=rpm,
            refill_per_second=rpm / 60,
            tokens=rpm,
            updated_at=now,
        )
        self.token_bucket = Bucket(
            capacity=tpm,
            refill_per_second=tpm / 60,
            tokens=tpm,
            updated_at=now,
        )
        self.lock = asyncio.Lock()

    async def acquire(self, estimated_tokens: int):
        async with self.lock:
            await self.request_bucket.acquire(1)
            await self.token_bucket.acquire(estimated_tokens)

Usage:

estimated_tokens = prompt_tokens + max_output_tokens
await limiter.acquire(estimated_tokens)

response = await client.chat.completions.create(
    model="gpt-5.5",
    messages=messages,
    max_tokens=max_output_tokens,
)

This is deliberately simple. In a distributed system, use Redis, Postgres advisory locks, or a queue with centralized dispatch. The important principle is the same: throttle on both request count and token budget before you call the API.

Retries: Backoff Is Necessary but Not Sufficient

Retries are useful for:

Retries are dangerous for:

The retry policy should classify errors first.

Error TypeRetry?Typical Action
429 rate_limit_exceededYesBackoff, respect retry headers, reduce concurrency
500/502/503/504YesBackoff with jitter, limited attempts
Timeout before response bodyMaybeRetry with idempotency key if supported
Context length exceededNoCompress, chunk, or route to longer-context model
Invalid API keyNoDisable key, alert operator
Content/safety rejectionNoReturn controlled failure or rewrite request
Tool side effect already executedNo/MaybeRequires idempotency design

The basic backoff shape:

import random

def retry_delay(attempt: int, base=0.5, cap=30.0):
    exponential = min(cap, base * (2 ** attempt))
    return random.uniform(0, exponential)

That random.uniform(0, exponential) is jitter. Do not skip it.

Without jitter:

100 workers fail
100 workers sleep 1s
100 workers retry
100 workers fail
100 workers sleep 2s
100 workers retry

With jitter:

100 workers fail
workers retry across a spread of 0-1s
then 0-2s
then 0-4s

The second pattern gives the provider and your own queue room to recover.

Respect Retry Headers When Present

Many APIs include a hint such as:

Retry-After: 12

or provider-specific reset headers. If the API gives you a concrete delay, use it as a floor:

delay = max(header_retry_after_seconds, retry_delay(attempt))
await asyncio.sleep(delay)

In practice, I also cap total retry time. A user-facing chat request should not quietly retry for two minutes while the user stares at a spinner. A background batch job can wait much longer.

Example policy:

{
  "interactive_chat": {
    "max_attempts": 3,
    "max_total_retry_seconds": 8
  },
  "batch_summarization": {
    "max_attempts": 8,
    "max_total_retry_seconds": 300
  }
}

Queueing Beats Worker Stampedes

If you have more work than quota, workers should not all directly call the LLM API. Put a queue in front.

A practical architecture:

App/API
  -> Job queue
      -> Rate-aware dispatcher
          -> Provider client
              -> Result store

The dispatcher owns:

This makes overload visible. Instead of thousands of tasks failing with 429, you see queue depth rising and estimated wait time increasing.

For Redis-backed systems, I like separating “ready to run” from “delayed retry” jobs:

llm:ready
llm:delayed
llm:dead_letter

A failed job does not immediately return to ready. It gets a scheduled retry timestamp. A small scheduler moves due jobs back into the ready queue.

{
  "job_id": "sum_7f13",
  "model": "claude-sonnet-4.6",
  "estimated_tokens": 6200,
  "attempt": 2,
  "not_before": "2026-02-03T09:14:22Z"
}

That not_before field prevents retry storms.

Concurrency Control Is Not the Same as Rate Limiting

Concurrency limits cap in-flight work. Rate limits cap work per time window.

You need both.

Imagine each call takes 20 seconds and your RPM is 60. A pure RPM limiter permits one request per second. After 20 seconds, you have about 20 concurrent calls. If latency jumps to 90 seconds, the same RPM creates 90 concurrent calls. That may exceed provider or local resource limits.

Use a semaphore for concurrency:

provider_concurrency = asyncio.Semaphore(20)

async def call_model(job):
    async with provider_concurrency:
        await limiter.acquire(job.estimated_tokens)
        return await send_request(job)

A common gotcha: acquire the semaphore too early and your workers sit idle holding scarce concurrency slots while waiting for rate tokens. Usually, acquire rate capacity first, then acquire concurrency immediately before the network call. If your limiter wait can be long, avoid occupying worker resources during that wait.

Graceful Degradation: Spend Quality Where It Matters

When rate limits bite, you have options besides failing.

For example, a customer support assistant might degrade like this:

Pressure LevelStrategyUser Impact
NormalUse strongest configured modelBest quality
Mild TPM pressureReduce max_tokens, trim retrieved contextSlightly shorter answers
Moderate pressureRoute simple tasks to smaller modelMinimal if classification/routing is easy
High pressureQueue non-urgent jobsDelayed background work
Severe pressureReturn cached or template responseLower quality but available

Concrete examples:

The trade-off is complexity. Every fallback path needs tests, observability, and product acceptance. A worse answer can be more damaging than a delayed answer in some domains.

Multi-Key and Multi-Provider Fallback

Multi-key and multi-provider routing can improve resilience, but it is easy to do badly.

There are three separate patterns:

PatternWhat It SolvesRisk
Multiple keys, same provider/accountOperational isolationMay violate provider terms if used to evade limits
Multiple accounts/regionsFault isolationMore billing and governance complexity
Multiple providers/modelsAvailability and cost flexibilityOutput variance, prompt incompatibility

Do not use multiple keys to sneak around limits. Use them for legitimate separation: tenants, environments, products, or billing boundaries.

A clean routing policy might look like:

{
  "task": "support_ticket_summary",
  "primary": {
    "provider": "anthropic",
    "model": "claude-sonnet-4.6"
  },
  "fallbacks": [
    {
      "provider": "openai",
      "model": "gpt-5.5",
      "when": ["provider_5xx", "rate_limit_after_retry_budget"]
    },
    {
      "provider": "google",
      "model": "gemini-3",
      "when": ["context_length_needed", "primary_unavailable"]
    }
  ],
  "degrade_to": {
    "provider": "open_model_cluster",
    "model": "qwen",
    "when": ["batch_low_priority"]
  }
}

If you use an aggregator or a platform like AI Prime Tech for multi-model access, the same engineering rules still apply: track idempotency, budgets, fallback conditions, and final billing semantics explicitly.

Avoiding Double-Billing on Fallback

The hard part of fallback is knowing whether the first request completed.

Consider this sequence:

Client sends request to Provider A
Provider A processes it successfully
Network connection drops before client receives response
Client retries with Provider B
Both providers bill
Two completions may exist

You cannot fully solve this from the client side unless the provider supports idempotency keys or request lookup. But you can reduce damage.

Use Idempotency Keys Where Available

Generate a stable key per logical operation:

import hashlib
import json

def idempotency_key(task_name, tenant_id, input_payload):
    canonical = json.dumps(input_payload, sort_keys=True, separators=(",", ":"))
    digest = hashlib.sha256(canonical.encode()).hexdigest()
    return f"{tenant_id}:{task_name}:{digest}"

Store the operation before sending:

{
  "operation_id": "acme:summarize:9d2a...",
  "status": "in_flight",
  "provider": "anthropic",
  "model": "claude-sonnet-4.6",
  "created_at": "2026-02-03T09:12:01Z"
}

Then update it after completion:

{
  "operation_id": "acme:summarize:9d2a...",
  "status": "completed",
  "provider_request_id": "req_abc123",
  "usage": {
    "input_tokens": 4180,
    "output_tokens": 612
  }
}

Before retrying elsewhere, check the operation table. If another worker already completed it, return the stored result.

Distinguish Timeout Types

Not all timeouts are equal:

For uncertain cases, I prefer a conservative retry policy:

interactive:
- retry connect timeout
- do not cross-provider retry after partial stream
- show recoverable error if status unknown

batch:
- retry after reconciliation delay
- prefer same provider with idempotency key
- fallback only after retry budget expires

Stream Carefully

Streaming improves perceived latency, but it complicates retries. Once you have sent partial output to the user, retrying with another model can produce a different continuation.

For streaming chat, I usually treat “first token received” as the point of no transparent retry. After that, failures become visible failures:

"The response was interrupted. Retry?"

That is less elegant than silent recovery, but it avoids stitching together incompatible outputs from different models.

Observability: The Metrics That Actually Help

You need more than success rate.

Track at least:

llm_requests_total{provider,model,status}
llm_tokens_reserved_total{provider,model}
llm_tokens_used_total{provider,model}
llm_retries_total{provider,model,error_type}
llm_queue_depth{priority,task_type}
llm_queue_wait_seconds{priority,task_type}
llm_rate_limit_hits_total{provider,model,limit_type}
llm_fallbacks_total{from_provider,to_provider,reason}
llm_duplicate_suppressed_total{task_type}

The most useful dashboard panel in real incidents is often not latency. It is:

queue_wait_seconds p50 / p95
rate_limit_hits by provider and model
tokens_reserved vs tokens_used
fallback rate by reason

If tokens_reserved is consistently much higher than tokens_used, your reservations are too conservative. If it is too close or below actual usage, you are oversubscribing and will hit TPM unexpectedly.

A Practical Retry Wrapper

Here is a compact pattern for retrying only eligible failures:

import asyncio
import random
import time

RETRYABLE_STATUS = {429, 500, 502, 503, 504}

class LLMError(Exception):
    def __init__(self, status, message="", retry_after=None):
        self.status = status
        self.retry_after = retry_after
        super().__init__(message)

def jittered_backoff(attempt, base=0.5, cap=20):
    return random.uniform(0, min(cap, base * (2 ** attempt)))

async def call_with_retries(fn, max_attempts=4, max_elapsed=20):
    started = time.monotonic()

    for attempt in range(max_attempts):
        try:
            return await fn()
        except LLMError as error:
            elapsed = time.monotonic() - started
            if error.status not in RETRYABLE_STATUS:
                raise
            if attempt == max_attempts - 1 or elapsed >= max_elapsed:
                raise

            delay = jittered_backoff(attempt)
            if error.retry_after is not None:
                delay = max(delay, error.retry_after)

            remaining = max_elapsed - elapsed
            await asyncio.sleep(min(delay, remaining))

The wrapper is intentionally boring. The sophistication should live in classification, queueing, idempotency, and routing—not in a magical retry loop that tries forever.

Practical Takeaways

RW
Ryan Walsh · Developer Tools & Claude Code

Ryan lives in the terminal with Claude Code and follows the Anthropic developer ecosystem closely — MCP servers, subagents, hooks, skills, and the coding-agent workflows developers actually ship with.

Get cheaper Claude API access

One API key for Claude Opus 4.8, Sonnet 4.6, Haiku 4.5, Fable 5, plus GPT & Gemini — up to 80% off official pricing, pay-as-you-go.

Get Your API Key →
AI Prime Tech is an independent third-party API gateway. Claude™ and Anthropic® are trademarks of Anthropic, PBC. No affiliation or endorsement is implied.