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.
429 Too Many Requests, or your own machine drowns in open sockets.
There are two distinct knobs, and mixing them up is the #1 mistake here:
Semaphore.token bucket.
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.
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.
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.
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.
The trap: "I set Semaphore(5), so I'm doing 5 per second." No. You're doing 5
at a time. Watch the arithmetic:
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.
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.
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.
acquire() โ using
time.monotonic() (immune to wall-clock jumps / NTP).time.sleep()
is essential โ sleeping while holding it would serialize every waiter and defeat the point.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.
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")
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.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.
| Tool | Caps | Question it answers |
|---|---|---|
Semaphore(N) | Concurrency | How many in flight at once? |
BoundedSemaphore(N) | Concurrency | Same, but catches over-release bugs |
max_workers | Concurrency | The pool's built-in semaphore |
TokenBucket(rate, cap) | Rate + burst | How many per second? |
with sem: always (auto-release on error), refill lazily with
time.monotonic(), and never sleep while holding the lock.