# Token bucket rate limiter in Python, with measured 429s

URL: https://fincodelabs.com/en/blog/token-bucket-rate-limiter-python · Publisher: Fincode (Fincode Yazılım ve Teknoloji A.Ş.), Istanbul, Turkey · Category: Exchange APIs · Published: 2026-11-06 · Updated: 2026-11-06

A token bucket rate limiter in Python for exchange APIs: thread, asyncio and multi-process code, weights, Retry-After, and real 429 counts from a local mock.

**Short answer:** A **token bucket rate limiter** allows short bursts while holding an average rate: it stores up to *capacity* tokens, refills at a fixed *rate*, and each request spends tokens. To stay under an exchange limit L per window W, keep capacity + rate × W ≤ L and share one bucket among everything that uses the same counter. On a local mock on 6 November 2026, a bucket sized to the limit itself had 0 to 9 of 40 requests rejected with HTTP 429, depending on where in a window it started; a correctly sized one had none.

Every count below comes from at least three runs of standard-library Python 3.11.3 (Windows 11, 6 November 2026) against a local mock like Binance's per-IP counter: 10 weight per fixed 1-second window, 429 with `Retry-After` above that, rejected requests not counted, usage in an `X-Used-Weight` header.

This is technical education, not a recommendation of any exchange or strategy; automated trading can lose money, and which platforms you may use depends on your jurisdiction and their terms. Article 99/A of Capital Markets Law No. 6362 treats activity by foreign crypto-asset platforms directed at Turkish residents as unauthorized.

## Token bucket algorithm in Python: a thread-safe class

The class keeps three numbers and refills on each call, with no background thread.

```python
import asyncio, json, math, threading, time

class TokenBucket:
    def __init__(self, rate, capacity, lock=None, state=None):
        if not all(math.isfinite(x) and x > 0 for x in (rate, capacity)):
            raise ValueError("rate and capacity must be positive and finite")
        if (lock is None) ^ (state is None):
            raise ValueError("pass lock and state together")
        self.rate, self.capacity = float(rate), float(capacity)
        self.lock = lock or threading.Lock()
        # tokens, last refill, resume time; starts empty, not full
        self.s = state if state is not None else [0.0, time.monotonic(), 0.0]

    def _take(self, cost):                    # returns 0 or seconds to wait
        if not (math.isfinite(cost) and 0 <= cost <= self.capacity):
            raise ValueError(f"cost {cost} outside 0..{self.capacity:g}")
        with self.lock:
            now = time.monotonic()
            tokens = min(self.capacity, self.s[0] + (now - self.s[1]) * self.rate)
            wait = max(self.s[2] - now, (cost - tokens) / self.rate)
            self.s[0], self.s[1] = (tokens - cost if wait <= 0 else tokens), now
            return max(wait, 0.0)

    def acquire(self, cost=1.0):
        while (wait := self._take(cost)) > 0:
            time.sleep(wait)                  # outside the lock

    async def acquire_async(self, cost=1.0):
        while (wait := self._take(cost)) > 0:
            await asyncio.sleep(wait)         # never time.sleep in a coroutine

    def pause(self, seconds):                 # e.g. after a 429
        if not (math.isfinite(seconds) and seconds >= 0):
            raise ValueError(f"pause {seconds}")
        with self.lock:
            now = time.monotonic()
            self.s[0], self.s[1] = 0.0, now
            self.s[2] = max(self.s[2], now + seconds)
```

- **Monotonic clock.** Python's documentation says `time.time()` can go back if the system clock is set back, removing tokens.
- **Starts empty.** After a restart the exchange may still count the previous run's requests.
- **Impossible values fail.** A cost above capacity raises `ValueError` instead of waiting forever, and infinity from `float("1e400")` cannot become a rate or a pause.
- **One pause for all.** A 429 seen by one thread holds every caller.
- **No queue.** A loop calling as fast as the bucket allows starves heavier calls: behind one thread taking cost-1 tokens nonstop (bucket 10 plus 10 a second), a cost-8 `acquire` still waited after 10 s. Give heavy calls their own bucket within the ceiling.

## Sizing a token bucket for an exchange rate limit

Keep capacity + rate × window ≤ limit, because any window can receive a full bucket plus one window of refill.

**40 requests from one thread, bucket full at start, mock limit 10 per 1-second window, 6 November 2026**

| Capacity, rate | Capacity + rate × 1 s | HTTP 429 |
| --- | --- | --- |
| 10, 10/s | 20 | 9 from a window boundary, every run; 0 to 8 from later starts |
| 2, 8/s | 10, exactly the limit | 0 |
| 2, 7/s | 9 | 0, in 5.4 s instead of about 4.8 s |

The oversized bucket passed when started late in a window, so a short test can miss it. The exact fit passed from every start tried, but over a real network a request sent just before a window boundary can land after it: keep a margin, as Bybit's rate limit page advises. RFC 6585 leaves counting to each server.

**How three exchanges count, official pages read 6 November 2026**

| Exchange | Counter | Window | Bucket |
| --- | --- | --- | --- |
| Binance Spot | 6,000 weight a minute per IP; unfilled orders per account, 100 per 10 seconds and 200,000 a day (live `exchangeInfo`) | Fixed, restarting at each whole minute | capacity + 60 × rate ≤ 6,000, and one per other entry |
| Bybit V5 | 600 requests per 5 seconds per IP; per-UID limits per endpoint | 5 seconds per IP; rolling 1 second per UID | capacity + 5 × rate ≤ 600; one per endpoint |
| Kraken Spot REST | A counter per API key: +1 a call, +2 for ledger and trade history, maximum 15 or 20 by tier | Decays 0.33 to 1 per second by tier | capacity ≤ maximum, rate ≤ decay; orders have a separate limiter |

> **Hypothetical sizing.** Keeping 20% of Binance Spot's 6,000 weight for other programs on the IP gives a ceiling of 4,800: a burst of 480 and a rate of (4,800 − 480) ÷ 60 = 72 per second. A 0.1% burst (capacity 4.8) would make `exchangeInfo`, weight 20 on 6 November 2026, raise `ValueError`. The [API rate limit calculator](https://fincodelabs.com/en/tools/api-rate-limit-calculator) budgets your endpoints.

## API rate limit reached: handling 429 and Retry-After

Pause the shared bucket for the server's wait, retry a bounded number of times, and stop at once on bans and authentication errors.

```python
import email.utils, urllib.error, urllib.request
from datetime import timezone

class Stop(Exception):
    """Not worth retrying."""

def retry_after(value, floor=1.0, cap=300.0):
    """Seconds from Retry-After (number or HTTP date); None if unusable."""
    try:
        seconds = float(value)
    except (TypeError, ValueError):
        try:
            when = email.utils.parsedate_to_datetime(value)
        except (TypeError, ValueError, IndexError, OverflowError):
            return None
        if when.tzinfo is None:               # "-0000" parses without a zone
            when = when.replace(tzinfo=timezone.utc)
        seconds = when.timestamp() - time.time()
    if not math.isfinite(seconds):            # "1e400" and "nan"
        return None
    if seconds > cap:
        raise Stop(f"asked to wait {seconds:.0f} s")
    return max(seconds, floor)                # "1e-400" parses to 0.0

def call(bucket, url, weight=1, tries=4, budget=None):
    """GET through the bucket; budget=(header, limit, window)."""
    for attempt in range(tries):
        bucket.acquire(weight)
        try:
            with urllib.request.urlopen(url, timeout=10) as r:
                status, headers, body = r.status, r.headers, r.read()
        except urllib.error.HTTPError as e:   # 4xx and 5xx are raised
            status, headers, body = e.code, e.headers, e.read()
        if status in (401, 403, 418):
            raise Stop(f"HTTP {status}: stop and alert")
        if status == 429:
            bucket.pause(retry_after(headers.get("Retry-After")) or 2.0 ** attempt)
            continue
        if status >= 300:
            raise Stop(f"HTTP {status}")
        used = headers.get(budget[0], "") if budget else ""
        if used.isdecimal() and int(used) + bucket.capacity >= budget[1]:
            bucket.pause(budget[2] - time.time() % budget[2])
        try:
            return json.loads(body)
        except ValueError:
            raise Stop(f"HTTP {status}: body is not JSON") from None
    raise Stop(f"still limited after {tries} tries")
```

**Scripted mock replies, 6 November 2026**

| Reply | Result |
| --- | --- |
| 429 with `Retry-After: 1e400`, then 200 | Infinity rejected; 1-second backoff; data after 1.0 s |
| `Retry-After` of `1e-400`, `-5` or a past date | 1-second floor |
| `Retry-After: 86400`; 429 four times with `Retry-After: 1`, or none | Stop at once; Stop after 3.0 s, or 7.0 s (1 + 2 + 4) |
| 401 with an error body, 418, 503, HTML with 200 | Stop, with no `KeyError` and no retry |
| Connection refused | `URLError` reaches the caller |

RFC 9110 allows `Retry-After` as seconds or an HTTP date; Binance sends seconds with 429 and with 418, its IP ban of 2 minutes to 3 days. `HTTPError` is a subclass of `URLError`, so catching `URLError` first files a 401 or a 429 under network errors. A 5XX on an order leaves its outcome unknown: look it up by [client order ID](https://fincodelabs.com/en/blog/client-order-id) before resending. Bybit's per-UID limit (`retCode` 10006) and Kraken's (`EAPI:Rate limit exceeded`) come in the JSON body, which `call()` returns as data on a 200, so check it too; signals for twelve exchanges are in [API rate limit exceeded](https://fincodelabs.com/en/blog/api-rate-limit-exceeded-crypto-exchanges).

## Weight-based rate limits: one bucket per counter

Pass each endpoint's weight as the cost and build one bucket per limit entry, because Binance Spot publishes four.

```python
SECONDS = {"SECOND": 1, "MINUTE": 60, "HOUR": 3600, "DAY": 86400}

def buckets_from(rate_limits, share=0.8, burst=0.1):
    buckets = {}                              # one per entry, not per type
    for e in rate_limits:
        key = (e["rateLimitType"], e["intervalNum"], e["interval"])
        if key in buckets:
            raise ValueError(f"duplicate limit {key}")
        window = e["intervalNum"] * SECONDS[e["interval"]]
        ceiling = e["limit"] * share
        buckets[key] = TokenBucket(ceiling * (1 - burst) / window, ceiling * burst)
    return buckets

url = "https://data-api.binance.vision/api/v3/exchangeInfo?symbol=BTCUSDT"
with urllib.request.urlopen(url, timeout=10) as r:
    limits = json.load(r).get("rateLimits")             # no KeyError
if not limits:
    raise Stop("no rateLimits in the reply")
print({e["rateLimitType"]: e for e in limits}["ORDERS"])   # wrong: last match wins
for key, b in buckets_from(limits).items():
    print(key, f"capacity {b.capacity:g}, rate {b.rate:g}/s")
```

Output on 6 November 2026:

```
{'rateLimitType': 'ORDERS', 'interval': 'DAY', 'intervalNum': 1, 'limit': 200000}
('REQUEST_WEIGHT', 1, 'MINUTE') capacity 480, rate 72/s
('ORDERS', 10, 'SECOND') capacity 8, rate 7.2/s
('ORDERS', 1, 'DAY') capacity 16000, rate 1.66667/s
('RAW_REQUESTS', 5, 'MINUTE') capacity 24000, rate 720/s
```

Keyed by type, the daily `ORDERS` entry silently replaced the 10-second one. Binance lets accounts whose orders keep filling keep placing them, so an `ORDERS` bucket errs on the safe side; endpoint weights are in the [Binance API rate limit](https://fincodelabs.com/en/blog/binance-api-rate-limit) guide.

A local bucket cannot see other programs on the same IP, but the header can: Binance documents `X-MBX-USED-WEIGHT-1M` as the IP's current used weight. On the mock, with a second program sending every 0.25 s, the bot's 30 calls (bucket 2 plus 7 a second) drew 3 429s in every run without `budget` and none with `budget=("X-Used-Weight", 10, 1.0)`. Binance's windows restart each whole minute, so keep the host clock synchronized.

## Asyncio rate limiter without blocking the event loop

Call `acquire_async`, which awaits `asyncio.sleep`, so other coroutines keep running while one waits.

Twenty tasks sharing a bucket of 2 plus 7 a second, with HTTP calls in `asyncio.to_thread`, all succeeded in 2.9 s without a 429. Calling the blocking `acquire` instead froze the loop: a heartbeat task due every 10 ms saw a 2.81 s gap in each run, enough to miss the pings a [WebSocket connection](https://fincodelabs.com/en/blog/websocket-heartbeat-reconnect) needs.

The `threading.Lock` guards only arithmetic, with no `await` inside, so one bucket serves threads and coroutines; Python's documentation says asyncio's own locks are not thread-safe. Eight threads sharing one bucket sent 40 requests with no 429.

## Why a per-process rate limiter fails with several workers

Each process that builds its own bucket gets the full rate, so four workers send four times the budget to one counter.

Four processes with their own buckets (2 plus 7 a second each) drew 5 to 12 server-side 429s for 40 requests in each of seven runs; sharing one bucket, they drew none. CCXT, likewise, keeps a limiter per exchange instance (see [CCXT rate limit](https://fincodelabs.com/en/blog/ccxt-rate-limit-error-handling)).

```python
import multiprocessing as mp

def worker(url, lock=None, state=None):
    bucket = TokenBucket(7, 2, lock=lock, state=state)
    for _ in range(10):
        call(bucket, url)

if __name__ == "__main__":
    lock = mp.Lock()
    state = mp.Array("d", [0.0, time.monotonic(), 0.0], lock=False)
    args = ("http://127.0.0.1:8642/", lock, state)       # the local mock
    procs = [mp.Process(target=worker, args=args) for _ in range(4)]
    for p in procs: p.start()
    for p in procs: p.join()
```

- **Shared state.** One `mp.Lock` guards an `mp.Array` created with `lock=False`. Python's documentation says `time.monotonic()` is the same clock for all processes (since 3.5; on macOS since 3.10).
- **Inheritance only.** Pass both as `Process` arguments or through a pool initializer; sending them through `pool.map` raised `RuntimeError: Lock objects should only be shared between processes through inheritance`.
- **Several machines.** Do not add IP addresses or keys to get around a limit unless the exchange allows it.

For integration work in general, see [Fincode's exchange API integration page](https://fincodelabs.com/en/services/exchange-api-integration); naming Binance, Bybit or Kraken here does not mean Fincode integrates them or has built on them.

## Frequently asked questions

### What is the token bucket algorithm?

Tokens refill at a steady rate up to a capacity and each request spends some, so bursts pass while the average holds.

### Is a token bucket the same as a leaky bucket?

As a meter, a leaky bucket is the same algorithm counted the other way, like Kraken's decaying counter; as a queue, it sends at a steady pace, like a token bucket with capacity 1.

### Why do I still get API rate limit reached errors with a limiter?

Usually several processes or programs share the exchange's counter, the bucket was sized to the limit, or retries ignore Retry-After.

### Does this limiter work across several servers?

No: it covers processes on one machine. Several machines need one gateway process, or a shared store with atomic updates and one clock.

## Sources

- [Binance Spot API documentation: REST API, limits and HTTP return codes](https://github.com/binance/binance-spot-api-docs/blob/master/rest-api.md)
- [Binance Spot API documentation: WebSocket API, rate limit intervals](https://github.com/binance/binance-spot-api-docs/blob/master/web-socket-api.md)
- [Binance Spot exchangeInfo, live rateLimits via the market data host (read 6 November 2026)](https://data-api.binance.vision/api/v3/exchangeInfo?symbol=BTCUSDT)
- [Bybit V5 API: Rate Limit Rules](https://bybit-exchange.github.io/docs/v5/rate-limit)
- [Kraken API: Spot REST Rate Limits](https://docs.kraken.com/exchange/guides/rest/ratelimits)
- [CCXT Manual: rate limit (each exchange instance has its own limiter)](https://github.com/ccxt/ccxt/wiki/Manual#rate-limit)
- [RFC 6585, section 4: 429 Too Many Requests](https://www.rfc-editor.org/rfc/rfc6585.html#section-4)
- [RFC 9110, section 10.2.3: Retry-After](https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.3)
- [Python documentation: time.monotonic and time.time](https://docs.python.org/3/library/time.html#time.monotonic)
- [Python documentation: asyncio synchronization primitives](https://docs.python.org/3/library/asyncio-sync.html)
- [Python documentation: multiprocessing, shared ctypes objects and start methods](https://docs.python.org/3/library/multiprocessing.html)
- [Python documentation: urllib.error (HTTPError is a subclass of URLError)](https://docs.python.org/3/library/urllib.error.html)
- [Capital Markets Law No. 6362, consolidated text, Article 99/A (Turkish)](https://www.mevzuat.gov.tr/mevzuatmetin/1.5.6362.pdf)

> This guide is general information about software and markets, not investment, legal or tax advice. Automated trading can lose money, past or backtested performance does not guarantee future results, and the rules that apply depend on your jurisdiction and your exchange's terms. Sources, checks and corrections: see the [editorial policy](https://fincodelabs.com/en/editorial-policy).

---

Published by Fincode (Fincode Yazılım ve Teknoloji A.Ş.), a software company in Istanbul, Turkey: https://fincodelabs.com/en/blog/token-bucket-rate-limiter-python

Cite as: Fincode, "Token bucket rate limiter in Python, with measured 429s", updated November 6, 2026, https://fincodelabs.com/en/blog/token-bucket-rate-limiter-python
