Skip to content
>GLB_
Go back

Batch API Requests in Python: Throughput vs Rate Limit Trade-Off

You have 200,000 records to push to an API that allows 10 requests per second. One record per request, sequentially, that’s at best 5.5 hours. Throw 100 threads at it and you get a wall of 429s. The useful question isn’t “how fast can I send?” but “which knob am I actually limited by?”

There are three knobs:

Concurrency: size it with Little’s Law

Little’s Law: in-flight = throughput × latency. Applied to HTTP:

C_needed = R × L

If the limit is 10 req/s and each request takes 400 ms, you need 10 × 0.4 = 4 concurrent requests to saturate the limit. With 1 you’re capped at 2.5 req/s. With 50 you don’t go faster; you just reach the limit sooner and start collecting 429s.

So concurrency is not a speed knob past R × L. It’s a latency hiding knob. Above that point, the rate limit is the bottleneck, and only batch size moves record throughput.

Batch size: where the real gain is

If the endpoint accepts b records per call, record throughput becomes:

records/s = min(R, C / L(b)) × b

The catch is L(b): bigger payloads take longer to serialize, transfer and process. A simple illustrative model, L(b) = 200 ms + 4 ms × b with R = 10 and C = 8 (these are model numbers, not a benchmark):

Batch sizeLatency L(b)Max req/s from C/LEffective req/sRecords/s
10.204 s39.210 (rate-bound)10
500.40 s20.010 (rate-bound)500
1000.60 s13.310 (rate-bound)1,000
2501.20 s6.76.7 (concurrency-bound)1,667
5002.20 s3.63.6 (concurrency-bound)1,818

Two things fall out of this:

  1. Going from 1 to 100 is a 100× gain because you stay rate-bound.
  2. Past ~200 the bottleneck flips to concurrency. You can push C up again (C ≥ R × L(b)), but gains flatten and each request now carries more risk.

What bigger batches cost you

Throughput isn’t free. Larger batches mean:

My default: pick the largest batch that keeps you rate-bound (not concurrency-bound) with a sane C, and stay well below the provider’s max.

Implementation: asyncio + httpx + token bucket

A semaphore limits concurrency. A token bucket limits rate. You need both: the semaphore alone doesn’t stop 8 fast requests from exceeding 10 req/s, and the bucket alone doesn’t stop slow requests from piling up connections.

# >GLB_ // rate-limited batch sender
import asyncio
import time
from collections.abc import Iterator, Sequence

import httpx


class TokenBucket:
    """Allow `rate` acquisitions per second, with bursts up to `capacity`."""

    def __init__(self, rate: float, capacity: int) -> None:
        self.rate = rate
        self.capacity = capacity
        self.tokens = float(capacity)
        self.updated = time.monotonic()
        self._lock = asyncio.Lock()

    async def acquire(self) -> None:
        while True:
            async with self._lock:
                now = time.monotonic()
                self.tokens = min(self.capacity, self.tokens + (now - self.updated) * self.rate)
                self.updated = now
                if self.tokens >= 1:
                    self.tokens -= 1
                    return
                wait = (1 - self.tokens) / self.rate
            await asyncio.sleep(wait)


def chunked(items: Sequence[dict], size: int) -> Iterator[Sequence[dict]]:
    for i in range(0, len(items), size):
        yield items[i : i + size]


async def send_batch(
    client: httpx.AsyncClient,
    bucket: TokenBucket,
    sem: asyncio.Semaphore,
    batch: Sequence[dict],
    max_retries: int = 5,
) -> dict:
    async with sem:
        for attempt in range(max_retries + 1):
            await bucket.acquire()
            resp = await client.post("/v1/records:batch", json={"records": list(batch)})
            if resp.status_code == 429 and attempt < max_retries:
                retry_after = float(resp.headers.get("Retry-After", 2 ** attempt))
                await asyncio.sleep(retry_after)
                continue
            resp.raise_for_status()
            return resp.json()
    raise RuntimeError("retries exhausted")


async def push_all(records: Sequence[dict], *, rate: float = 10, concurrency: int = 8, batch_size: int = 100):
    bucket = TokenBucket(rate=rate, capacity=int(rate))
    sem = asyncio.Semaphore(concurrency)
    limits = httpx.Limits(max_connections=concurrency, max_keepalive_connections=concurrency)

    async with httpx.AsyncClient(
        base_url="https://api.example.com",
        limits=limits,
        timeout=httpx.Timeout(30.0),
    ) as client:
        tasks = [send_batch(client, bucket, sem, b) for b in chunked(records, batch_size)]
        results = await asyncio.gather(*tasks, return_exceptions=True)

    failed = [r for r in results if isinstance(r, Exception)]
    print(f"batches: {len(results)}, failed: {len(failed)}")
    return results


if __name__ == "__main__":
    data = [{"id": i, "value": i * 2} for i in range(20_000)]
    asyncio.run(push_all(data))

Notes:

Measure, don’t guess

Before tuning, log per-request latency and status code, then compute:

If p95 latency grows faster than linearly with b, the server is struggling with large payloads and you’ve passed the useful batch size.

Summary

References


Share this post:

Related Posts


Previous Post
Greenfield vs Brownfield Projects: What Actually Changes in Engineering
Next Post
Partitioning Strategy in Data Lakes: When It Hurts Performance