Skip to article
API429 / Field notes

AI API Adaptive Throttling: Rate-Limit Headers, Token Buckets, Ramp-Up, and Load Shedding

Build client-side adaptive throttling for Gemini and OpenAI-compatible APIs using Retry-After headers, token-bucket burst pacing, staged ramp-up, and brownout load shedding.

Hourglass and a queue of paper request cards
Editorial photo illustration: Hourglass and a queue of paper request cards. Topic: AI API Adaptive Throttling: Rate-Limit Headers, Token Buckets, Ramp-Up, and Load Shedding.

Static request-per-minute ceilings fail when AI workloads shift between short interactive turns, long-context prompts, and multi-step tool calls. A fixed limiter either starves healthy workers during normal traffic or allows synchronized bursts to trigger cascading HTTP 429 Too Many Requests responses when token volume spikes. Production reliability requires client-side adaptive throttling inside your application dispatch layer—combining normalized response signals, local token-bucket burst pacing, staged ramp-up after cold starts, and deterministic brownout load shedding.

This consolidated engineering guide brings together adaptive throttling, rate-limit header normalization, acceleration ramp-up controls, token-bucket burst shaping, load shedding, and brownout mode into one operational reference. It complements Gemini API rate limits and queue admission design and application retry budgets.

Normalize rate-limit headers and 429 signals without inventing upstream state

Different providers and gateway routes expose different rate-limit metadata. Direct OpenAI and Anthropic endpoints return vendor-specific headers alongside standard HTTP status codes, whereas direct Google Gemini API rate limits enforce project-level RPM, input_TPM, and RPD counters that return 429 RESOURCE_EXHAUSTED when any single dimension saturates. When calling an OpenAI-compatible gateway such as the routes documented in the API429 documentation and public OpenAPI spec, your client must parse standard HTTP signals first and never assume undocumented provider headers are present on every response.

Observed signal Primary specification or source What the client can conclude Immediate throttle controller action
HTTP 429 with Retry-After (delay-seconds or HTTP-date) RFC 6585 §4, RFC 9110 §10.2.3 Upstream requires a minimum wait floor before new dispatches Set lane cooldown floor to at least Retry-After plus positive jitter; Multiplicative Decrease on dispatch rate
HTTP 429 without Retry-After Standard HTTP / gateway response Capacity or rate limit reached; exact counter dimension is unknown Apply conservative exponential cooldown and reduce local acceptance multiplier
Vendor headers (x-ratelimit-remaining-requests, x-ratelimit-remaining-tokens, anthropic-ratelimit-*) Direct OpenAI or Anthropic responses Point-in-time remaining budget on that specific direct credential Slow token-bucket refill proactively when remaining ratio drops below 15%
Elevated queue wait age or HTTP 503 overload Local worker telemetry / upstream status Dispatch rate exceeds sustainable completion throughput Enter brownout mode: pause background lanes and shed optional enrichment
GET /v1/models and GET /api/client/balance API429 customer docs Point-in-time model visibility and account credit observation Reject unconfigured models or unfunded jobs early; never treat as a rate-limit reservation

Always keep credential-scoped catalog checks and workload health checks separate from live throttle state. A healthy catalog check on /en/models or Google provider endpoints does not mean your burst bucket can skip pacing.

Combine token-bucket burst control with staged ramp-up limits

Even when average request volume stays below your nominal quota, micro-bursts—such as fifty webhook workers waking at the top of a minute—exhaust per-second capacity and trigger HTTP 429. At the same time, abruptly shifting 100% of traffic onto a cold worker pool or newly migrated model route creates acceleration spikes before adaptive counters stabilize.

Your dispatch layer should enforce two complementary pacing controls:

  1. Dual token buckets (requests and estimated input tokens): Require every dispatch to claim both one request token and estimated_input_tokens from local buckets that refill smoothly per second rather than resetting on a hard 60-second boundary. Pair this with pre-call token estimation from the Gemini token counting guide.
  2. Staged acceleration ramp-up: When starting a cold deployment, recovering from a cooldown, or migrating traffic from Claude Code, Cursor, or n8n automation workflows, cap maximum allowed dispatch growth per window (for example, stepping through 10%, 25%, 50%, and 100% of target concurrency only while observed 429 rate stays below 1% and tail latency remains stable).

Activate brownout mode and deterministic load shedding under pressure

Adaptive throttling cannot save an overloaded system if rejected or delayed requests simply pile up in an unbounded memory queue. When the adaptive acceptance multiplier drops below your operational threshold or queue wait time threatens caller deadlines, transition deterministically through four operating modes:

Operating mode Trigger threshold (application-defined) Interactive user traffic Optional features & context Background & bulk queues
Normal Multiplier >= 0.85, 429 rate < 1%, queue age within target Full admission within tenant budget Full prompt context and optional tool calls enabled Admitted using spare token-bucket capacity
Paced ramp / caution Multiplier 0.60 - 0.84 or cold-start ramp window active Admitted with strict per-second token-bucket pacing Cap optional secondary lookups and cap max output tokens Deferred when remaining bucket headroom is below 30%
Brownout mode Multiplier 0.35 - 0.59 or sustained Retry-After cooldowns Prioritized; preserve core user action Downshift optional enrichment, summaries, or non-critical repair loops Paused at admission; hold jobs in durable store
Load shedding Multiplier < 0.35 or queue wait exceeds remaining deadline Admit only critical interactive calls that fit remaining deadline Strip non-essential steps; return explicit degraded contract Immediately shed (rejected or deferred with terminal reason) before hitting network

Load shedding must always return a machine-readable state so callers know no paid model execution occurred and can defer or stop cleanly rather than firing blind retries.

Runnable offline Python adaptive throttle and brownout controller

The following self-contained Python 3.10+ module implements header normalization, dual request/token buckets, staged ramp-up caps, adaptive rejection probability, and brownout/load-shedding transitions using deterministic synthetic inputs (no network calls or API keys required):

from __future__ import annotations
from dataclasses import dataclass
from email.utils import parsedate_to_datetime
from datetime import datetime, timezone
from typing import Mapping


@dataclass(frozen=True)
class ThrottleDecision:
    action: str          # "admit", "admit_brownout", "defer_cooldown", "shed_load"
    mode: str            # "normal", "caution", "brownout", "load_shedding"
    wait_seconds: float
    multiplier: float
    reason: str


def parse_retry_after(headers: Mapping[str, str], now_epoch: float) -> float:
    raw = headers.get("retry-after") or headers.get("Retry-After")
    if not raw:
        return 0.0
    value = raw.strip()
    if value.isdigit():
        return max(0.0, float(value))
    try:
        dt = parsedate_to_datetime(value)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return max(0.0, dt.timestamp() - now_epoch)
    except (ValueError, TypeError):
        return 0.0


class AdaptiveThrottleController:
    def __init__(self, max_rpm: float, max_input_tpm: float, ramp_cap: float = 0.25) -> None:
        self.max_rpm = max_rpm
        self.max_input_tpm = max_input_tpm
        self.ramp_cap = ramp_cap
        self.req_tokens = max_rpm * ramp_cap
        self.input_tokens = max_input_tpm * ramp_cap
        self.multiplier = 1.0
        self.cooldown_until = 0.0
        self.last_tick = 0.0

    def advance(self, now: float) -> None:
        if self.last_tick <= 0.0:
            self.last_tick = now
            return
        elapsed = max(0.0, now - self.last_tick)
        self.last_tick = now
        effective_cap = min(self.multiplier, self.ramp_cap)
        req_rate = (self.max_rpm / 60.0) * effective_cap
        tok_rate = (self.max_input_tpm / 60.0) * effective_cap
        self.req_tokens = min(self.max_rpm * effective_cap, self.req_tokens + elapsed * req_rate)
        self.input_tokens = min(self.max_input_tpm * effective_cap, self.input_tokens + elapsed * tok_rate)

    def record_outcome(self, status_code: int, headers: Mapping[str, str], now: float) -> None:
        self.advance(now)
        if status_code == 429:
            retry_wait = parse_retry_after(headers, now)
            jitter = 0.25
            self.cooldown_until = max(self.cooldown_until, now + max(retry_wait, 2.0) + jitter)
            self.multiplier = max(0.20, round(self.multiplier * 0.65, 4))
        elif 200 <= status_code < 300:
            self.multiplier = min(1.0, round(self.multiplier + 0.03, 4))
            self.ramp_cap = min(1.0, round(self.ramp_cap + 0.05, 4))

    def current_mode(self) -> str:
        if self.multiplier < 0.35:
            return "load_shedding"
        if self.multiplier < 0.60:
            return "brownout"
        if self.multiplier < 0.85 or self.ramp_cap < 1.0:
            return "caution"
        return "normal"

    def evaluate(
        self,
        *,
        priority: str,
        estimated_input_tokens: int,
        remaining_deadline_s: float,
        min_execution_s: float,
        now: float,
    ) -> ThrottleDecision:
        self.advance(now)
        mode = self.current_mode()
        if remaining_deadline_s < min_execution_s:
            return ThrottleDecision("shed_load", mode, 0.0, self.multiplier, "deadline_budget_insufficient")
        if now < self.cooldown_until:
            wait_s = round(self.cooldown_until - now, 3)
            if priority != "critical" or wait_s + min_execution_s > remaining_deadline_s:
                return ThrottleDecision("shed_load", mode, wait_s, self.multiplier, "active_retry_after_cooldown")
            return ThrottleDecision("defer_cooldown", mode, wait_s, self.multiplier, "wait_for_cooldown_floor")
        if mode == "load_shedding" and priority != "critical":
            return ThrottleDecision("shed_load", mode, 5.0, self.multiplier, "non_critical_shed_under_pressure")
        if mode == "brownout" and priority == "background":
            return ThrottleDecision("shed_load", mode, 10.0, self.multiplier, "background_paused_in_brownout")
        if self.req_tokens < 1.0 or self.input_tokens < estimated_input_tokens:
            return ThrottleDecision("defer_cooldown", mode, 1.5, self.multiplier, "token_bucket_burst_cap")

        self.req_tokens -= 1.0
        self.input_tokens -= estimated_input_tokens
        if mode == "brownout":
            return ThrottleDecision("admit_brownout", mode, 0.0, self.multiplier, "admitted_with_optional_steps_disabled")
        return ThrottleDecision("admit", mode, 0.0, self.multiplier, "admitted_within_paced_budget")


if __name__ == "__main__":
    ctrl = AdaptiveThrottleController(max_rpm=120.0, max_input_tpm=120000.0, ramp_cap=0.50)
    ctrl.record_outcome(429, {"Retry-After": "4"}, now=100.0)
    ctrl.record_outcome(429, {}, now=105.0)
    bg = ctrl.evaluate(
        priority="background",
        estimated_input_tokens=2000,
        remaining_deadline_s=20.0,
        min_execution_s=3.0,
        now=108.0,
    )
    live = ctrl.evaluate(
        priority="critical",
        estimated_input_tokens=1500,
        remaining_deadline_s=20.0,
        min_execution_s=3.0,
        now=108.0,
    )
    print("Synthetic background decision:", bg)
    print("Synthetic live decision:", live)

When reviewing model pricing on /en/pricing or checking answers in /en/faq, remember that client-side throttling protects both upstream rate-limit headroom and your application's error budget.

FAQ

How does adaptive throttling differ from exponential backoff?

Exponential backoff decides how long a single failed request waits before its next retry attempt. Adaptive throttling adjusts the dispatch rate, burst buckets, and admission rules for all incoming requests on that workload lane so new traffic does not collide with recovering retries.

Can my gateway or client rely on x-ratelimit-* headers on every AI API route?

No. While direct OpenAI and Anthropic endpoints return vendor-specific rate-limit headers, direct Google Gemini endpoints and OpenAI-compatible gateways may return standard HTTP 429 responses with or without Retry-After. Normalize standard Retry-After headers first and fall back to observed 429 ratios and latency when vendor headers are absent.

Why do we need acceleration ramp-up limits when our average RPM is below quota?

Sudden step-changes in traffic—such as cold-start queue drains, cron batches firing on the minute, or instant traffic cutovers—create steep per-second request and token spikes. Staged ramp-up caps paired with per-second token buckets smooth those bursts before they trigger 429 cascades.

What is the difference between brownout mode and load shedding?

Brownout mode keeps core requests running by disabling optional enrichments, reducing maximum output tokens, and pausing background queues. Load shedding occurs when capacity is still insufficient or caller deadlines cannot be met, deterministically rejecting lower-priority requests before they consume network sockets or model tokens.

Sources & further reading

  1. RFC 6585 section 4: 429 Too Many Requests
  2. RFC 9110 section 10.2.3: Retry-After
  3. Google Gemini API rate limits
  4. Google Gemini API troubleshooting guide
  5. OpenAI API rate limits guide
  6. Anthropic API rate limits reference
  7. API429 customer documentation
  8. Current API429 public OpenAPI
  9. API429 public catalog contract

Put the next step into practice.

Check model IDs, current pricing and request formats before integrating. Availability can depend on the model and provider route.

Telegram