Skip to content
v0.1.1latest release34 skills2 categories4 sub-categories79 tested scripts157 reference docsContact us

Concurrency Limits and Rate Limiting

Reference for python-asyncio-pattern-advisor · 428 lines

Programming / Pythonv1.0.0Updated OCT 21 20267 references1 script

Limiting how many operations run at once (concurrency) and how many start per unit of time (rate) are different controls. LLM providers enforce rate (requests per minute, tokens per minute), your machine and the upstream's connection pool enforce concurrency. Production code needs both, plus backpressure so that producers slow down when consumers cannot keep up.

Contents

Concurrency vs rate

Little's law ties them together: throughput = concurrency / latency.

ConcurrencyMean latencyThroughputPer minute
102.0 s5 req/s300 RPM
502.0 s25 req/s1,500 RPM
500.2 s (embeddings)250 req/s15,000 RPM

So a Semaphore of 50 can exceed a 500 RPM limit by 30x when calls are fast. A concurrency limit protects sockets, memory and the upstream's connection count; a rate limiter keeps you under quota. When latency drops (cache hits, short prompts), only the rate limiter still holds the line.

Semaphore patterns

python
from __future__ import annotations

import asyncio
from collections import defaultdict


class KeyedLimiter:
    """Separate concurrency cap per key (host, provider, tenant)."""

    def __init__(self, default: int, overrides: dict[str, int] | None = None) -> None:
        overrides = overrides or {}
        self._sems: dict[str, asyncio.Semaphore] = defaultdict(lambda: asyncio.Semaphore(default))
        for key, n in overrides.items():
            self._sems[key] = asyncio.Semaphore(n)

    def __call__(self, key: str) -> asyncio.Semaphore:
        return self._sems[key]


async def main() -> None:
    limits = KeyedLimiter(default=4, overrides={"slow-site.example": 1})
    active: dict[str, int] = defaultdict(int)
    peak: dict[str, int] = defaultdict(int)

    async def fetch(host: str) -> None:
        async with limits(host):
            active[host] += 1
            peak[host] = max(peak[host], active[host])
            await asyncio.sleep(0.01)
            active[host] -= 1

    hosts = ["slow-site.example"] * 5 + ["fast.example"] * 10
    await asyncio.gather(*(fetch(h) for h in hosts))
    print(dict(peak))


asyncio.run(main())
# Output: {'slow-site.example': 1, 'fast.example': 4}
  • Use BoundedSemaphore when releases are manual: an extra release() raises ValueError instead of silently raising the cap.
  • Hold the semaphore only around the limited resource (the HTTP call), not around post-processing.
  • On 3.9, asyncio primitives (Semaphore, Lock, Queue, Event) created outside a running loop bind to get_event_loop() at construction and then fail under asyncio.run with "attached to a different loop". Since 3.10 they bind lazily on first use. Create them inside the running loop to be safe on 3.9.

Sliding window over huge inputs

gather + Semaphore still creates one task per item up front: 1M tasks is ~1 GB of RAM. A sliding window keeps at most limit tasks alive and pulls items lazily from any iterable or async iterable:

python
import asyncio
from typing import AsyncIterator, Awaitable, Callable, Iterable, Tuple, TypeVar

T = TypeVar("T")
R = TypeVar("R")


async def bounded_map(fn: Callable[[T], Awaitable[R]], items: Iterable[T], limit: int) -> AsyncIterator[Tuple[T, R]]:
    """Yield (item, result) as they complete, with at most `limit` in flight. Errors propagate."""
    it = iter(items)
    pending: dict[asyncio.Task, T] = {}

    def refill() -> None:
        while len(pending) < limit:
            try:
                item = next(it)
            except StopIteration:
                return
            pending[asyncio.ensure_future(fn(item))] = item

    refill()
    try:
        while pending:
            done, _ = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
            for task in done:
                item = pending.pop(task)
                yield item, task.result()
            refill()
    finally:
        for task in pending:                       # consumer stopped early or an error escaped
            task.cancel()
        await asyncio.gather(*pending, return_exceptions=True)


async def main() -> None:
    peak = active = 0

    async def embed(i: int) -> int:
        nonlocal peak, active
        active += 1
        peak = max(peak, active)
        await asyncio.sleep(0.001)
        active -= 1
        return i * 2

    total = 0
    async for _, vec in bounded_map(embed, range(1000), limit=8):
        total += vec
    print(total, "peak", peak)


asyncio.run(main())
# Output: 999000 peak 8

Backpressure with bounded queues

Backpressure means the slow stage pushes back on the fast stage. With asyncio.Queue(maxsize=n), await queue.put(x) suspends the producer while the queue is full. Without maxsize (the default, 0 = unbounded) a fast reader of a 50 GB corpus fills RAM while embeddings crawl along (linter rule AIO021).

Stage pairQueue size guideline
Fetch -> parse2 x parser workers
Parse -> embed (batched)2 x batch size
Anything -> GPU worker1-2 batches; the GPU is the bottleneck, more buffering only adds latency

put_nowait() raises asyncio.QueueFull instead of waiting; use it when dropping or rejecting is the right policy (e.g. a live telemetry buffer, or returning HTTP 503 when a request queue is full).

Token bucket

A bucket holds up to capacity tokens and refills at rate per second; each request takes one (or cost) tokens and waits if there are not enough. Capacity sets the allowed burst.

python
import asyncio
import time


class TokenBucket:
    def __init__(self, rate: float, capacity: float) -> None:
        if rate <= 0 or capacity <= 0:
            raise ValueError("rate and capacity must be positive")
        self.rate, self.capacity = rate, capacity
        self._tokens = capacity
        self._updated = time.monotonic()
        self._lock = asyncio.Lock()          # FIFO fairness: waiters are served in arrival order

    def _refill(self) -> None:
        now = time.monotonic()
        self._tokens = min(self.capacity, self._tokens + (now - self._updated) * self.rate)
        self._updated = now

    async def acquire(self, cost: float = 1.0) -> None:
        if cost > self.capacity:
            raise ValueError(f"cost {cost} exceeds bucket capacity {self.capacity}")
        async with self._lock:
            while True:
                self._refill()
                if self._tokens >= cost:
                    self._tokens -= cost
                    return
                await asyncio.sleep((cost - self._tokens) / self.rate)

    def refund(self, amount: float) -> None:
        self._refill()
        self._tokens = min(self.capacity, self._tokens + amount)


async def main() -> None:
    bucket = TokenBucket(rate=50, capacity=5)          # 50 req/s, burst of 5
    start = time.monotonic()
    await asyncio.gather(*(bucket.acquire() for _ in range(30)))
    elapsed = time.monotonic() - start
    print("30 requests:", 0.45 <= elapsed <= 0.8)      # 5 immediately, 25 more at 50/s = 0.5 s


asyncio.run(main())
# Output: 30 requests: True

For a limit "N per minute", use rate = N / 60. Set capacity small (a few seconds' worth) if the provider enforces the limit over short sub-windows; many providers do, so a full minute of burst at once can still return 429. aiolimiter.AsyncLimiter(max_rate, time_period) is a maintained library implementation of the same leaky-bucket idea.

RPM and TPM together

LLM APIs limit requests and tokens independently; whichever runs out first throttles you. Charge the request bucket 1 and the token bucket the estimated tokens (prompt + max output), reserved before sending.

python
import asyncio


class DualRateLimiter:
    """Waits until both the request-per-minute and token-per-minute budgets allow the call."""

    def __init__(self, rpm: int, tpm: int, burst_seconds: float = 6.0) -> None:
        self.requests = TokenBucket(rate=rpm / 60, capacity=max(1.0, rpm / 60 * burst_seconds))
        self.tokens = TokenBucket(rate=tpm / 60, capacity=max(1.0, tpm / 60 * burst_seconds))

    async def acquire(self, tokens: int) -> None:
        # Take tokens first: it is the scarcer budget for long prompts, and holding a request
        # slot while waiting for tokens would starve short requests.
        await self.tokens.acquire(min(tokens, self.tokens.capacity))
        await self.requests.acquire(1)

    def reconcile(self, estimated: int, actual: int) -> None:
        """Give back over-estimated tokens once the response reports real usage."""
        if actual < estimated:
            self.tokens.refund(estimated - actual)

(TokenBucket is the class above.) A request bigger than the bucket capacity is clamped so it can still go through once the bucket is full; size burst_seconds so tpm / 60 * burst_seconds exceeds your largest request, or such requests will be admitted early.

Wrap the client call:

python
async def chat(limiter: DualRateLimiter, sem: asyncio.Semaphore, client, messages, max_tokens: int = 512):
    estimate = estimate_tokens(messages) + max_tokens
    async with sem:                                   # concurrency (sockets, memory)
        await limiter.acquire(estimate)               # rate (RPM + TPM)
        resp = await with_retry(lambda: client.complete(messages, max_tokens=max_tokens))
    limiter.reconcile(estimate, resp.usage.total_tokens)
    return resp

Estimating and reconciling tokens

MethodAccuracyCost
len(text) / 4 (English)+/- 20-30%free
Provider tokenizer (e.g. tiktoken for OpenAI models)exact for that model family~microseconds per KB, CPU
Provider token-count endpointexactan extra request (counts against RPM on some providers)
usage field in the responseexact, after the factfree; use it to reconcile

Estimate conservatively before the call (include the system prompt, tool schemas and images), then refund the difference from usage. For embeddings, TPM is usually the binding limit: batch inputs (e.g. 100-2,048 texts per request, model dependent) and charge the batch's total tokens.

Tokenizing large prompts is CPU work; for big batches run it in asyncio.to_thread or once per document at ingestion time, not per request on the loop.

Reading provider rate-limit headers

Providers return remaining budget and reset times in response headers; using them avoids guessing:

Provider familyHeaders (examples)
OpenAI-compatiblex-ratelimit-remaining-requests, x-ratelimit-remaining-tokens, x-ratelimit-reset-requests, x-ratelimit-reset-tokens
Anthropicanthropic-ratelimit-requests-remaining, anthropic-ratelimit-tokens-remaining, ...-reset (RFC 3339 time)
Most HTTP APIs on 429/503Retry-After (seconds or HTTP date)

Header names and formats change; treat them as hints, log them, and always fall back to backoff. On a 429: sleep Retry-After (plus jitter) in the task that got it and tell the shared limiter, so other tasks stop sending too:

python
class PausableLimiter:
    def __init__(self) -> None:
        self._resume_at = 0.0

    def pause_for(self, seconds: float) -> None:
        loop = asyncio.get_running_loop()
        self._resume_at = max(self._resume_at, loop.time() + seconds)

    async def wait(self) -> None:
        loop = asyncio.get_running_loop()
        delay = self._resume_at - loop.time()
        if delay > 0:
            await asyncio.sleep(delay)

Adaptive concurrency (AIMD)

When limits are unknown or shared with other services, adapt: additive increase on success, multiplicative decrease on 429/timeouts (the TCP congestion-control idea).

python
import asyncio


class RateLimited(Exception):
    pass


class AdaptiveLimiter:
    def __init__(self, initial: int = 8, minimum: int = 1, maximum: int = 128) -> None:
        self.limit, self.minimum, self.maximum = initial, minimum, maximum
        self.in_flight = 0
        self._cond = asyncio.Condition()

    async def __aenter__(self) -> "AdaptiveLimiter":
        async with self._cond:
            await self._cond.wait_for(lambda: self.in_flight < self.limit)
            self.in_flight += 1
        return self

    async def __aexit__(self, exc_type, exc, tb) -> None:
        async with self._cond:
            self.in_flight -= 1
            if exc_type is None:
                self.limit = min(self.maximum, self.limit + 1 / self.limit)   # about +1 per window
            elif issubclass(exc_type, (RateLimited, TimeoutError)):
                self.limit = max(self.minimum, int(self.limit * 0.5))         # halve on overload
            self._cond.notify_all()


async def main() -> None:
    limiter = AdaptiveLimiter(initial=16)
    for _ in range(3):
        try:
            async with limiter:
                raise RateLimited("429")
        except RateLimited:
            pass
    print("limit after three 429s:", limiter.limit)


asyncio.run(main())
# Output: limit after three 429s: 2

Increase is fractional per success so the limit grows by about one per "window" of successful requests. Combine with the RPM/TPM buckets when the provider documents its limits.

Per-provider limiter registry

Multi-model apps (router, fallbacks, judge models in evals) need separate budgets per provider/model/key:

python
from dataclasses import dataclass, field


@dataclass
class ModelBudget:
    rpm: int
    tpm: int
    max_concurrency: int
    limiter: "DualRateLimiter" = field(init=False)
    sem: asyncio.Semaphore = field(init=False)

    def __post_init__(self) -> None:
        self.limiter = DualRateLimiter(self.rpm, self.tpm)
        self.sem = asyncio.Semaphore(self.max_concurrency)


BUDGETS = {
    "provider-a/large": ModelBudget(rpm=500, tpm=200_000, max_concurrency=32),
    "provider-a/small": ModelBudget(rpm=5_000, tpm=2_000_000, max_concurrency=128),
    "provider-b/embed": ModelBudget(rpm=3_000, tpm=1_000_000, max_concurrency=64),
}

Build the registry inside the running loop (lifespan / main()), not at import time on 3.9. Numbers above are placeholders: read your account's actual tier limits from the provider console.

Connection pools must match

ClientSettingDefault
httpx.AsyncClientlimits=httpx.Limits(max_connections=..., max_keepalive_connections=...)100 / 20
httpxtimeout=httpx.Timeout(read, connect=..., pool=...)5 s for all
aiohttp.ClientSessionconnector=aiohttp.TCPConnector(limit=..., limit_per_host=...)100 / 0 (unlimited per host)
asyncpg.create_poolmin_size, max_size10 / 10
Official LLM SDK async clientswrap an httpx client; pass http_client= to tuneSDK specific

If your Semaphore allows more in-flight calls than the pool has connections, extra calls wait inside the client and can fail with a pool timeout that looks like a network problem. Keep semaphore <= max_connections, and remember the default 5 s httpx timeout is far too short for LLM generations (set read timeouts of 60-600 s depending on output length).

Several processes or pods

In-process limiters only see their own traffic. With N replicas either:

  • give each replica limit / N (simple, wastes headroom when traffic is uneven), or
  • use a shared limiter (Redis token bucket via a Lua script, or a gateway/proxy that enforces limits), or
  • send all LLM calls through one queue consumed by a fixed worker fleet.

Always keep 429 handling in the client too: shared limiters drift, and providers apply limits you do not see.

Choosing numbers

  1. Start from provider limits: RPM, TPM, maximum concurrent requests (some providers have one).
  2. Measure p50/p95 latency and average tokens per request.
  3. Concurrency needed to hit the rate: rpm / 60 * p50_latency, plus ~20% headroom; cap by memory and pool size.
  4. TPM check: requests_per_minute * avg_tokens <= tpm; if not, TPM is your real limit, size from that.
  5. Burst: 2-10 seconds' worth of rate.
  6. Load-test with production-like prompt sizes; watch 429 rate (target well under 1%), p95 latency, queue depth and event-loop lag (see debugging-asyncio.md).
More in Python

python-async-deadlock-debugger

Diagnose and fix hangs, deadlocks, livelocks and starvation in asyncio, threaded and multiprocessing Python: classify CPU spin vs blocked IO vs deadlock vs lost wakeup vs event loop blocked by sync code, collect evidence with faulthandler, py-spy dump, gdb py-bt, asyncio.all_tasks, python -m asyncio ps/pstree, debug mode and sys._current_frames, then fix lock-order cycles, non-reentrant asyncio.Lock re-acquisition, run_coroutine_threadsafe().result() on the loop thread, Queue.join without task_done, Condition lost wakeups, semaphore leaks, futures nobody resolves, nested event loops, thread pool exhaustion, multiprocessing Queue/fork deadlocks and shutdown hangs. Ships a static hang scanner and a watchdog that dumps every thread and asyncio task.

python-beginner-concept-tutor

Teach Python concepts to beginners heading into AI engineering (data scientists, analysts, students, career switchers): plain-language definitions, analogies, minimal examples, line-by-line walkthroughs, common mistakes, check-your-understanding questions and mini exercises; Socratic hints before solutions; explaining tracebacks; mutable vs immutable, references and aliasing, is vs ==, integer division, floating point, slicing and off-by-one, indentation errors; a staged learning path from variables to calling an LLM API, embeddings and a tiny RAG. Ships a script that lists the concepts a snippet uses in learning order and traces execution step by step like Python Tutor.

python-comprehension-refactorer

Refactor Python loops into list, dict and set comprehensions and generator expressions, and unroll comprehensions that are too clever: filtering, mapping, nested-loop flattening, conditional expressions, walrus in comprehensions, dict inversion, grouping with defaultdict/Counter, lazy generators for sum/any/all/max, streaming large files, itertools (chain, islice, groupby, batched, pairwise, accumulate, product), scoping rules, side-effect anti-patterns and performance (when map/filter or numpy/pandas vectorization win). AI-engineering focus: building prompt lists, batching texts for embeddings, filtering retrieved chunks by score, id->embedding dicts, flattening tool-call results, lazy dataset streaming and token counting.

python-context-manager-writer

Write correct Python context managers and find resource-handling bugs — __enter__/__exit__ semantics and exception suppression, contextlib (contextmanager, asynccontextmanager, ExitStack, AsyncExitStack, closing, aclosing, suppress, redirect_stdout, nullcontext, chdir), async with, re-entrancy, leaked files/connections/clients/pools/locks, and AI-engineering recipes: torch.no_grad/inference_mode/autocast, eval()/train() switches, GPU cache cleanup, token and latency meters, tracing spans, temp env vars, rate-limit semaphores, httpx/aiohttp client lifecycle, FastAPI lifespan, DB/vector-store transactions, atomic checkpoint writes, seeds and agent sandboxes.

python-dataclass-converter

Convert boilerplate Python classes, dicts, tuples and JSON payloads into dataclasses, or into pydantic v2 BaseModel, TypedDict, NamedTuple, attrs or msgspec when that fits better. Covers every @dataclass option (frozen, slots, kw_only, order, unsafe_hash, match_args, weakref_slot), field() (default_factory, init=False, repr, compare, hash, metadata), __post_init__, InitVar, KW_ONLY, inheritance and default ordering, replace(), asdict deep-copy pitfalls, hashing/equality, serialization and validation. AI focus: LLM request/response and chat Message types, RAG chunks with embeddings (numpy eq gotcha), eval records, configs, typed OpenAI/Anthropic JSON.

python-exception-handling-designer

Design and review Python exception handling: custom exception hierarchies with retryable flags, what to catch where, EAFP vs LBYL, chaining with raise ... from, else/finally, logger.exception, add_note, ExceptionGroup and except* with asyncio.TaskGroup, warnings, asserts vs validation, FastAPI error translation, retries with backoff and jitter, timeouts, cancellation, pytest.raises, plus LLM-app failures: 429 rate limits with retry-after, 5xx overloaded, context-length exceeded, content filters, broken streams, malformed JSON repair loops, tool-call errors fed back to the model, partial embedding batch failures, CUDA OOM batch halving, vector DB outages, circuit breakers and model fallbacks.