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 × 2n) for retry n.
| 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.
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))
| 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.
| 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 and 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
newClientOrderIdis 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. - 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.
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.
HTTPErrorsubclassesURLErrorand soOSError: catchingOSErrorfirst would retry a 401; here it took 1 call.- The blind retry bought twice;
place_ordersent 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.
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 explains it. For integration work in general, see Fincode's 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 (opens in a new tab)
- AWS samples: backoff simulator code with the jitter formulas (aws-arch-backoff-simulator) (opens in a new tab)
- Amazon Builders' Library: Timeouts, retries, and backoff with jitter, Marc Brooker, 5 December 2019 (opens in a new tab)
- RFC 9110 HTTP Semantics: 9.2.2 Idempotent Methods, 10.2.3 Retry-After, 15.6.4 503 Service Unavailable (opens in a new tab)
- RFC 6585: Additional HTTP Status Codes, section 4, 429 Too Many Requests (opens in a new tab)
- Binance Spot API documentation: REST API (HTTP return codes, limits, 10-second timeout, newClientOrderId) (opens in a new tab)
- Binance Spot API documentation: Error codes (-1007 TIMEOUT) (opens in a new tab)
- Binance Spot API documentation: WebSocket API (rate limits, retryAfter) (opens in a new tab)
- Binance USD-M Futures: General info (HTTP 408, 429, 418 and 503 variants, last modified 30 October 2026) (opens in a new tab)
- Bybit V5 API: Rate Limit Rules (opens in a new tab)
- urllib3 2.8.0 documentation: Utilities, Retry (opens in a new tab)
- urllib3 source at tag 2.8.0: util/retry.py (backoff, allowed methods, Retry-After parsing) (opens in a new tab)
- Python documentation: urllib.error (HTTPError is a subclass of URLError) (opens in a new tab)
- Python documentation: email.utils.parsedate_to_datetime (opens in a new tab)
- Capital Markets Law No. 6362, consolidated text, Article 99/A (Turkish) (opens in a new tab)
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.