# Exponential backoff with jitter in Python: tested retries

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

Exponential backoff with jitter in Python: full, equal and decorrelated formulas simulated, strict Retry-After parsing, and safe retries for timed-out orders.

**Short answer:** **Exponential backoff with jitter** makes each retry wait a random time from a window that doubles after every failure, up to a cap, so clients rejected together do not return together. In a simulation run on 6 November 2026, it cut the time 100 clients took to clear a 10-per-second limit from 151.5 seconds to 16 to 22 seconds. A timed-out order needs a lookup, not a retry.

Everything here was run or read on 6 November 2026 (Python 3.11.3, standard library, Windows 11). Binance and Bybit appear only to describe their documentation: which platforms you may use depends on your jurisdiction and their terms, and automated trading can lose money. Article 99/A of Capital Markets Law No. 6362 treats activity by foreign crypto-asset platforms directed at Turkish residents as unauthorized.

## Exponential backoff with jitter: full, equal and decorrelated formulas

All three randomize the capped window v = min(cap, base × 2^n) for retry n.

**Formulas in AWS's published backoff simulator, read 6 November 2026**

| Variant | Wait before retry n | Shortest wait |
| --- | --- | --- |
| No jitter | v | v |
| Full jitter | uniform(0, v) | 0 |
| Equal jitter | v / 2 + uniform(0, v / 2) | v / 2 |
| Decorrelated jitter | min(cap, uniform(base, 3 × previous wait)) | base |

The names come from Marc Brooker's AWS blog post of 4 March 2015. In Python, `0.5 * 2 ** 1024` raises `OverflowError`, about 8.5 hours into a loop retrying forever at a 30-second cap without jitter, so bound the exponent.

## Thundering herd retries: 100 and 1,000 clients simulated

Any jitter cut calls and time sharply; the best variant changed with the client count.

The model: all clients call at once; the server admits 10 calls per 1-second window and rejects the rest; base 0.5 seconds, cap 30; means of 200 seeded runs.

```python
import heapq, math, random, statistics

def waits(kind, base, cap, rng):
    n, last = 0, base
    while True:
        v = min(cap, base * 2 ** min(n, 60))          # bounded exponent
        if kind == "full":
            v = rng.uniform(0, v)
        elif kind == "equal":
            v = v / 2 + rng.uniform(0, v / 2)
        elif kind == "decorrelated":
            v = last = min(cap, rng.uniform(base, last * 3))
        yield v
        n += 1

def herd(kind, seed, clients, per_second=10, base=0.5, cap=30.0):
    rng = random.Random(seed)
    gen = [waits(kind, base, cap, rng) for _ in range(clients)]
    queue, used, calls, end = [(0.0, c) for c in range(clients)], {}, 0, 0.0
    while queue:
        t, c = heapq.heappop(queue)
        calls, w = calls + 1, math.floor(t)
        if used.get(w, 0) < per_second:
            used[w], end = used.get(w, 0) + 1, t
        else:                                          # HTTP 429: back off
            heapq.heappush(queue, (t + next(gen[c]), c))
    return calls, end

for clients in (100, 1000):
    for kind in ("none", "full", "equal", "decorrelated"):
        runs = [herd(kind, seed, clients) for seed in range(200)]
        print(clients, kind, round(statistics.mean(r[0] for r in runs)),
              round(statistics.mean(r[1] for r in runs), 1))
```

**Script output, 6 November 2026; the lower bound is perfect pacing**

| Backoff | 100 clients: calls | Last success (s) | 1,000 clients: calls | Last success (s) |
| --- | ---: | ---: | ---: | ---: |
| Lower bound | 100 | 9.0 | 1,000 | 99.0 |
| No jitter | 640 | 151.5 | 51,490 | 2,851.5 |
| Full jitter | 488 | 22.1 | 9,133 | 117.4 |
| Equal jitter | 452 | 17.6 | 8,288 | 127.5 |
| Decorrelated jitter | 374 | 16.1 | 9,549 | 123.4 |

Without jitter, clients return in waves. Brooker's post, modeling contention for one database row, found full jitter did the least work; with no network delay here, the no-jitter row is a worst case.

## Retry-After header: seconds, HTTP dates and bad values

Under RFC 9110, `Retry-After` is a whole number of seconds or an HTTP date; ignore anything else.

Via `float()`, 1e400 becomes infinity, which `time.sleep` rejects with `OverflowError`; nan and -1 raise `ValueError`, and a huge zone offset made `parsedate_to_datetime` raise `OverflowError`, not the documented `ValueError`.

**Official documentation read 6 November 2026**

| Venue | Limit signal | Wait information |
| --- | --- | --- |
| Binance Spot | 429, then 418 IP bans of 2 minutes to 3 days | REST: `Retry-After` in seconds; WebSocket API: `retryAfter`, a timestamp in milliseconds |
| Binance USD-M futures | 429, then 418 | `Retry-After` not mentioned |
| Bybit V5 | "403, access too frequent" above 600 requests per 5 seconds per IP; `retCode` 10006 per user ID | Wait at least 10 minutes after the 403 |

`Retry-After` also synchronizes workers given the same value, so add jitter on top. Limits by exchange: [API rate limit exceeded](https://fincodelabs.com/en/blog/api-rate-limit-exceeded-crypto-exchanges) and [Binance API rate limit](https://fincodelabs.com/en/blog/binance-api-rate-limit).

## Is it safe to retry a POST? Not an order that timed out

No: a POST that timed out, or returned 408 or a 5XX, may already have reached the matching engine.

- RFC 9110, section 9.2.2: do not retry a non-idempotent request automatically unless it is idempotent in effect or you can tell the original was never applied.
- Binance Spot (read 6 November 2026): a 5XX leaves execution status unknown, as does `-1007`, returned when the matching engine takes over 10 seconds: query the order's status.
- Binance USD-M futures (last modified 30 October 2026): verify a 503 "Unknown error" first; retry "Service Unavailable" with backoff, such as 200, 400 and 800 ms, at most 3 to 5 attempts.
- Binance Spot's `newClientOrderId` is unique among open orders only, so a market order that filled during the timeout can be bought twice under one ID; see [client order ID](https://fincodelabs.com/en/blog/client-order-id).
- urllib3 2.8.0 (15 September 2026) excludes POST from its default retry methods and adds no jitter unless asked.

## Exponential backoff in Python: a retry wrapper tested on a mock

The wrapper retries reads with full jitter on top of `Retry-After`, never loops on auth errors or bans, and meets an order timeout with a lookup. The mock on 127.0.0.1 keeps IDs unique among open orders only, as Binance Spot documents, and can reply after the client's 0.5-second timeout; no request reached a real exchange.

```python
import email.utils, http.client, json, random, re, threading, time
import urllib.error, urllib.parse, urllib.request
from datetime import timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

class GiveUp(Exception):
    pass

def retry_after(value, limit=60):
    value = (value or "").strip()
    if re.fullmatch(r"[0-9]+", value):             # delay-seconds: digits only
        seconds = int(value) if len(value) < 10 else limit + 1
    else:
        try:
            when = email.utils.parsedate_to_datetime(value)
            if when.tzinfo is None:                 # "-0000" means UTC
                when = when.replace(tzinfo=timezone.utc)
            seconds = max(0.0, when.timestamp() - time.time())
        except (TypeError, ValueError, OverflowError):
            return None
    if seconds > limit:
        raise GiveUp(f"Retry-After {value}: pause every worker on this IP")
    return float(seconds)

def backoff(attempt, base=0.05, cap=0.5):          # full jitter, bounded exponent
    return random.uniform(0, min(cap, base * 2 ** min(attempt, 30)))

UNKNOWN = (408, 500, 502, 503, 504)                 # the request may have run
RETRY = (429,) + UNKNOWN

def call(method, path, body=None, key="test-key"):
    req = urllib.request.Request(BASE + path, method=method, headers={"X-Key": key},
                                 data=None if body is None else json.dumps(body).encode())
    with urllib.request.urlopen(req, timeout=0.5) as r:
        return json.loads(r.read())

def get(path, attempts=5, key="test-key"):
    for attempt in range(attempts):
        try:
            return call("GET", path, key=key)
        except urllib.error.HTTPError as e:
            if e.code not in RETRY:
                raise                               # 400, 401, 403, 418: never loop
            floor = retry_after(e.headers.get("Retry-After")) or 0.0
        except (OSError, ValueError, http.client.HTTPException):
            floor = 0.0
        time.sleep(floor + backoff(attempt))
    raise GiveUp(f"GET {path} failed {attempts} times")

def place_order(order, key="test-key", settle=0.3):
    cid = str(order.get("clientOrderId") or "").strip()
    if not cid:
        raise ValueError("clientOrderId required; nothing sent")
    order = dict(order, clientOrderId=cid)          # send the ID you look up
    for attempt in range(2):                        # at most one resend
        try:
            return call("POST", "/order", order, key)
        except urllib.error.HTTPError as e:
            if e.code not in UNKNOWN:
                raise                               # rejected or limited: caller decides
        except (OSError, ValueError, http.client.HTTPException):
            pass                                    # it may exist
        time.sleep(settle)                          # wait out the venue's processing
        found = get("/order?" + urllib.parse.urlencode({"clientOrderId": cid}), key=key)
        if len(found) > 1:
            raise GiveUp(f"{len(found)} orders carry {cid}: reconcile")
        if found:
            return found[0]                         # it arrived: do not resend
    raise GiveUp(f"{cid}: outcome unknown")

class Mock(BaseHTTPRequestHandler):
    script, orders, hits, slow = [], [], [], 0

    def reply(self, status, body, headers=()):
        data = json.dumps(body).encode()
        self.send_response(status)
        for header in headers:
            self.send_header(*header)
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def do_GET(self):
        Mock.hits.append("GET")
        if self.path == "/time":
            return self.reply(*(Mock.script.pop(0) if Mock.script else (200, {})))
        query = urllib.parse.parse_qs(self.path.partition("?")[2])
        cid = query.get("clientOrderId", [""])[0]
        self.reply(200, [o for o in Mock.orders if o["clientOrderId"] == cid])

    def do_POST(self):
        Mock.hits.append("POST")
        if self.headers["X-Key"] not in {"test-key"}:
            return self.reply(401, {"msg": "invalid key"})
        order = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
        cid = order.get("clientOrderId") or ""
        duplicate = any(o["clientOrderId"] == cid and o["status"] == "NEW" for o in Mock.orders)
        if not cid or duplicate:
            return self.reply(400, {"msg": "missing or duplicate clientOrderId"})
        saved = dict(order, status="FILLED" if order["type"] == "MARKET" else "NEW")
        Mock.orders.append(saved)
        if Mock.slow:                               # answer after the client gave up
            Mock.slow -= 1
            time.sleep(1.0)
        self.reply(200, saved)

    def log_message(self, *args):
        pass

server = ThreadingHTTPServer(("127.0.0.1", 0), Mock)
server.daemon_threads, server.handle_error = True, lambda *args: None
threading.Thread(target=server.serve_forever, daemon=True).start()
BASE = f"http://127.0.0.1:{server.server_port}"
count = lambda cid: sum(o["clientOrderId"] == cid for o in Mock.orders)

for value in ("7", "Fri, 31 Dec 1999 23:59:59 GMT", "1e400", "nan",
              "1 Jan 2000 0:00 +99999999999999", "86400"):
    try:
        print(f"Retry-After {repr(value)}: {retry_after(value)}")
    except GiveUp as e:
        print("GiveUp:", e)

Mock.script = [(429, {}, [("Retry-After", "1")]), (503, {})]
start, n = time.monotonic(), len(Mock.hits)
get("/time")
print(f"429 then 503: {len(Mock.hits) - n} calls in {time.monotonic() - start:.0f} s")

n = len(Mock.hits)
try:
    place_order({"clientOrderId": "k-1", "type": "LIMIT"}, key="wrong-key")
except urllib.error.HTTPError as e:
    print(f"wrong key: HTTP {e.code} after {len(Mock.hits) - n} call")

order = {"clientOrderId": "m-1", "type": "MARKET", "side": "BUY", "qty": "0.01"}
Mock.slow = 1
for attempt in range(2):                            # a blind retry
    try:
        call("POST", "/order", order)
        break
    except TimeoutError:
        pass
print("blind retry, orders with m-1:", count("m-1"))

Mock.slow, n = 1, len(Mock.hits)
result = place_order(dict(order, clientOrderId="m-2"))
print("place_order m-2:", result["status"], Mock.hits[n:], "orders with m-2:", count("m-2"))

Mock.orders.append({"clientOrderId": "r-7", "status": "FILLED"})   # before a restart
Mock.slow = 1
for cid in ("r-7", " "):
    try:
        place_order(dict(order, clientOrderId=cid))
    except (GiveUp, ValueError) as e:
        print(f"{repr(cid)}: {e}")
```

Output:

```
Retry-After '7': 7.0
Retry-After 'Fri, 31 Dec 1999 23:59:59 GMT': 0.0
Retry-After '1e400': None
Retry-After 'nan': None
Retry-After '1 Jan 2000 0:00 +99999999999999': None
GiveUp: Retry-After 86400: pause every worker on this IP
429 then 503: 3 calls in 1 s
wrong key: HTTP 401 after 1 call
blind retry, orders with m-1: 2
place_order m-2: FILLED ['POST', 'GET'] orders with m-2: 1
'r-7': 2 orders carry r-7: reconcile
' ': clientOrderId required; nothing sent
```

## Failure cases the tests catch, and stopping retry storms

Each output line tests a common retry bug; budgets stop retry storms.

- `HTTPError` subclasses `URLError` and so `OSError`: catching `OSError` first would retry a 401; here it took 1 call.
- The blind retry bought twice; `place_order` sent one POST and one lookup. With a reused ID and a lost resend, it returned the older order: never reuse IDs.
- Retry in one layer, on a budget: in the Builders' Library article (5 December 2019), independent retries in a five-deep stack raise database load 243 times; AWS added a retry token bucket to its SDK in 2016.
- Share one resume time across processes, since Binance and Bybit count per IP, and jitter reconnects too; see [WebSocket heartbeat and reconnect](https://fincodelabs.com/en/blog/websocket-heartbeat-reconnect).

> **Hypothetical example.** 40 workers on one IP use a library retrying 3 times inside a wrapper retrying 3 times: one failed batch can send 40 × 4 × 4 = 640 requests.

On a live venue, wait at least its processing timeout before calling an order missing, and let `GiveUp` block new orders until [position reconciliation](https://fincodelabs.com/en/blog/position-reconciliation) explains it. For integration work in general, see Fincode's [exchange API integration](https://fincodelabs.com/en/services/exchange-api-integration) page; naming Binance or Bybit here does not mean Fincode integrates them or has built on them.

## Frequently asked questions

### What is the formula for exponential backoff with jitter?

Full jitter waits a random time between 0 and min(cap, base x 2^attempt). Equal jitter fixes half that window; decorrelated jitter draws between base and three times the last wait, capped.

### Which jitter strategy is best?

In runs on 6 November 2026 all three beat no jitter by far, and the best changed with load. Full jitter is simplest.

### How do I parse Retry-After in Python?

Accept digits as seconds or an HTTP date via email.utils.parsedate_to_datetime, and ignore anything else.

### Can I retry a timed-out order with the same client order ID?

Only after the venue's processing timeout and a lookup showing it does not exist. Binance Spot IDs are unique among open orders only, so a resend after a fill makes a second order.

## Sources

- [AWS Architecture Blog: Exponential Backoff And Jitter, Marc Brooker, 4 March 2015](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)
- [AWS samples: backoff simulator code with the jitter formulas (aws-arch-backoff-simulator)](https://github.com/aws-samples/aws-arch-backoff-simulator/blob/master/src/backoff_simulator.py)
- [Amazon Builders' Library: Timeouts, retries, and backoff with jitter, Marc Brooker, 5 December 2019](https://builder.aws.com/content/3EumjoZascWd1oZiEgL8ORlv3qE/timeouts-retries-and-backoff-with-jitter)
- [RFC 9110 HTTP Semantics: 9.2.2 Idempotent Methods, 10.2.3 Retry-After, 15.6.4 503 Service Unavailable](https://www.rfc-editor.org/rfc/rfc9110.html)
- [RFC 6585: Additional HTTP Status Codes, section 4, 429 Too Many Requests](https://www.rfc-editor.org/rfc/rfc6585.html#section-4)
- [Binance Spot API documentation: REST API (HTTP return codes, limits, 10-second timeout, newClientOrderId)](https://github.com/binance/binance-spot-api-docs/blob/master/rest-api.md)
- [Binance Spot API documentation: Error codes (-1007 TIMEOUT)](https://github.com/binance/binance-spot-api-docs/blob/master/errors.md)
- [Binance Spot API documentation: WebSocket API (rate limits, retryAfter)](https://github.com/binance/binance-spot-api-docs/blob/master/web-socket-api.md)
- [Binance USD-M Futures: General info (HTTP 408, 429, 418 and 503 variants, last modified 30 October 2026)](https://developers.binance.com/en/docs/products/derivatives-trading-usds-futures/general-info)
- [Bybit V5 API: Rate Limit Rules](https://bybit-exchange.github.io/docs/v5/rate-limit)
- [urllib3 2.8.0 documentation: Utilities, Retry](https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html)
- [urllib3 source at tag 2.8.0: util/retry.py (backoff, allowed methods, Retry-After parsing)](https://github.com/urllib3/urllib3/blob/2.8.0/src/urllib3/util/retry.py)
- [Python documentation: urllib.error (HTTPError is a subclass of URLError)](https://docs.python.org/3/library/urllib.error.html)
- [Python documentation: email.utils.parsedate_to_datetime](https://docs.python.org/3/library/email.utils.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/exponential-backoff-jitter-python

Cite as: Fincode, "Exponential backoff with jitter in Python: tested retries", updated November 6, 2026, https://fincodelabs.com/en/blog/exponential-backoff-jitter-python
