๐ŸŽซ Pattern 11: Rate Limiting & Concurrency Caps

You have 500 URLs to fetch โ€” but firing 500 threads at one server is a great way to get your IP banned. Here's how to hold the reins.

The Problem: Two Different "Too Fast"

Scenario: you spin up a pool and launch 500 requests. The remote server chokes, returns 429 Too Many Requests, or your own machine drowns in open sockets.

Goal: stay polite. But "polite" means two different things you must not confuse.

There are two distinct knobs, and mixing them up is the #1 mistake here:

Key Insight: A semaphore caps how many at once; it says nothing about how often. If each request takes 1 ms, a semaphore of 5 still lets you fire thousands per second. A rate limiter caps how often; it says nothing about how many overlap. Real APIs usually need both.

Capping Concurrency with a Semaphore

A threading.Semaphore(N) is a counter that starts at N. acquire() decrements it (blocking at zero); release() increments it. Wrap the critical work in with sem: and at most N threads can be inside at once โ€” everyone else waits at the gate.

flowchart LR Q["8 waiting threads"] --> G{{"Semaphore(3)
3 permits"}} G -->|permit| W1["worker (in flight)"] G -->|permit| W2["worker (in flight)"] G -->|permit| W3["worker (in flight)"] W1 -.release.-> G W2 -.release.-> G W3 -.release.-> G
import threading, time, random

sem = threading.Semaphore(3)          # at most 3 workers in the critical section
active = 0
active_lock = threading.Lock()

def fetch(url):
    global active
    with sem:                         # blocks here once 3 are already inside
        with active_lock:
            active += 1
            print(f"{url}: in flight (now {active} active)")
        time.sleep(random.uniform(0.2, 0.6))   # simulate the request
        with active_lock:
            active -= 1
    # permit auto-released on exit โ€” even if the body raised

threads = [threading.Thread(target=fetch, args=(f"url-{i}",)) for i in range(8)]
for t in threads: t.start()
for t in threads: t.join()
# You'll never see "now 4 active" โ€” the cap holds.
Why with sem: not manual acquire/release: same reason as locks โ€” if the worker raises, the context manager still releases the permit. A leaked permit permanently shrinks your pool until it deadlocks.
Note: threading.BoundedSemaphore(N) is the safer default โ€” it raises ValueError if you release() more times than you acquire(), catching the classic "released twice" bug instead of silently letting the cap drift upward.

Why a Semaphore Is Not a Rate Limiter

The trap: "I set Semaphore(5), so I'm doing 5 per second." No. You're doing 5 at a time. Watch the arithmetic:

If each request takes 10 ms and you allow 5 concurrent, you complete roughly 5 / 0.010 = 500 requests/second โ€” far over a "10 req/s" API budget. The semaphore never fired, because you were never at 5 in flight for long. Concurrency was low; rate was enormous.

To bound requests per unit of time, you need something that tracks the clock. Enter the token bucket.

A Thread-Safe Token Bucket

The token bucket is the standard rate-limiting algorithm. A bucket holds up to capacity tokens and refills at rate tokens per second. Each request must acquire() a token; if the bucket is empty, the caller sleeps until enough have dripped back in. The capacity is your burst allowance; the rate is your steady-state ceiling.

flowchart TB R["refill: +rate tokens/sec
(capped at capacity)"] --> B[("Bucket
0 โ€ฆ capacity tokens")] B -->|"acquire(): take 1 token"| OK["token available โ†’ proceed"] B -->|"empty"| WAIT["sleep until one drips in"] WAIT --> B
import threading, time

class TokenBucket:
    """Rate-limit to `rate` operations/sec, allowing bursts up to `capacity`."""

    def __init__(self, rate: float, capacity: float):
        self.rate = rate                 # tokens added per second
        self.capacity = capacity         # max tokens (burst size)
        self.tokens = capacity           # start full
        self.updated_at = time.monotonic()
        self.lock = threading.Lock()

    def _refill(self):
        now = time.monotonic()
        elapsed = now - self.updated_at
        # add tokens for the time that passed, never exceeding capacity
        self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
        self.updated_at = now

    def acquire(self, amount: float = 1.0):
        while True:
            with self.lock:
                self._refill()
                if self.tokens >= amount:
                    self.tokens -= amount
                    return
                # not enough yet โ€” compute how long until we have `amount`
                deficit = amount - self.tokens
                wait = deficit / self.rate
            time.sleep(wait)             # sleep OUTSIDE the lock

bucket = TokenBucket(rate=5, capacity=5)   # 5/sec steady, burst of 5

def call_api(i):
    bucket.acquire()                        # blocks to stay under the limit
    print(f"{time.monotonic():.2f}s  request {i}")

threads = [threading.Thread(target=call_api, args=(i,)) for i in range(15)]
for t in threads: t.start()
for t in threads: t.join()
# First 5 fire immediately (the burst), then ~5 per second after.
Two things that make this correct:
  • Lazy refill. No background thread dripping tokens. We compute how many would have accrued from elapsed time on each acquire() โ€” using time.monotonic() (immune to wall-clock jumps / NTP).
  • Sleep outside the lock. Releasing the lock before time.sleep() is essential โ€” sleeping while holding it would serialize every waiter and defeat the point.
Subtlety: this recomputes wait in a loop rather than trusting one sleep โ€” because several threads may wake together and race for the same freshly-dripped token. The loser simply re-checks and sleeps again. Correct, if slightly busy under heavy contention.

Combining Both with a ThreadPoolExecutor

In real code you rarely hand-roll threads โ€” you use a pool. The pool's max_workers is your concurrency cap (it plays the semaphore's role), and the token bucket, called at the top of each task, enforces the rate. Two independent knobs, cleanly separated.

from concurrent.futures import ThreadPoolExecutor
import time

bucket = TokenBucket(rate=10, capacity=10)   # โ‰ค 10 req/s

def fetch(url):
    bucket.acquire()                          # rate gate โ€” may block
    # ... do the actual request here ...
    return f"fetched {url}"

urls = [f"https://api.example.com/item/{i}" for i in range(100)]

# max_workers=5  โ†’ never more than 5 requests IN FLIGHT (concurrency cap)
# bucket rate=10 โ†’ never more than 10 requests PER SECOND (rate cap)
with ThreadPoolExecutor(max_workers=5) as pool:
    results = list(pool.map(fetch, urls))

print(len(results), "done")
The clean mental model:
  • max_workers (or a Semaphore) โ†’ how many at once. Protects your resources: sockets, memory, file handles.
  • TokenBucket โ†’ how often. Protects the remote service and respects its published quota.
Real-world: most third-party APIs (Stripe, GitHub, OpenAI) publish a requests/second or requests/minute quota and will drop connections if you open too many at once. You genuinely need both caps. When you get a 429, honor the Retry-After header โ€” a token bucket is your steady-state budget, not a substitute for backing off when the server says stop.

Key Takeaways

ToolCapsQuestion it answers
Semaphore(N)ConcurrencyHow many in flight at once?
BoundedSemaphore(N)ConcurrencySame, but catches over-release bugs
max_workersConcurrencyThe pool's built-in semaphore
TokenBucket(rate, cap)Rate + burstHow many per second?
Remember: with sem: always (auto-release on error), refill lazily with time.monotonic(), and never sleep while holding the lock.