Handle errors and retries
Which failures are worth retrying, which aren't, and how to back off correctly.
The single most useful habit: branch on error.code, not on the HTTP status alone. The status
tells you the category; the code tells you whether retrying can possibly help.
The decision table
code | Status | Retry? |
|---|---|---|
rate_limited | 429 | Yes — honour Retry-After, usually a second or two. |
quota_exceeded | 429 | No. Out of requests until X-Quota-Reset. Retrying burns nothing but achieves nothing. |
bulk_quota_exceeded | 429 | No. A Standard account that first subscribed on or after 30 October 2026 has used its 250 bulk-export requests for the month. Other endpoints still work; it resets at X-Quota-Reset. |
trial_quota_exceeded | 429 | No. Legacy trials only: subscribe to continue. |
trial_endpoint_quota_exceeded | 429 | No — not on that endpoint. Legacy trials only: its per-endpoint ceiling is used up, but other endpoints still work. |
sandbox_quota_exceeded | 429 | No. Sandbox keys only: the 2,000 requests are used and never reset. |
sample_quota_exceeded | 429 | No, not today. The public sample key only: its daily limit is used up, for your network address or for everyone together. Not counted. Wait for 00:00 UTC (Retry-After says how long), or use your own key. |
sandbox_scope | 403 | No. Sandbox keys and the public sample key only: the request is outside the sample. Not counted. |
internal_error | 500 | Yes — with exponential backoff. |
unavailable | 503 | Yes — with backoff. |
invalid_parameter | 400 | No — fix the request. |
invalid_cursor | 400 | No — restart pagination from the first page. |
not_found / unknown_game | 404 | No. |
missing_api_key / invalid_api_key | 401 | No — fix credentials. |
The two 429s are opposites
Treating every 429 as "back off and retry" turns a quota exhaustion into a tight retry loop that can never succeed. Read the code.
A retry wrapper
Using Python?
The official Python client, installed with
pip install esportsodds, applies this table for you: it waits out Retry-After on
rate_limited, retries 503 and, at most twice, 500, never retries quota_exceeded, and
raises a typed exception for each code. The wrapper below is for when you would rather own the
loop.
import os, time, random, requests
BASE = "https://api.esportsodds.gg"
HEADERS = {"Authorization": f"Bearer {os.environ['ESPORTSODDS_API_KEY']}"}
RETRYABLE = {"rate_limited", "internal_error", "unavailable"}
class ApiError(RuntimeError):
def __init__(self, code, message, request_id):
super().__init__(f"{code}: {message} (request {request_id})")
self.code = code
def get(path, params=None, attempts=5):
for attempt in range(attempts):
r = requests.get(BASE + path, params=params, headers=HEADERS, timeout=30)
if r.ok:
return r.json()
err = r.json().get("error", {})
code = err.get("code", "")
if code not in RETRYABLE or attempt == attempts - 1:
raise ApiError(code, err.get("message", ""), err.get("request_id"))
# Server-supplied delay wins; otherwise exponential backoff with jitter so a fleet of
# workers doesn't retry in lockstep.
wait = float(r.headers.get("Retry-After") or 2 ** attempt)
time.sleep(wait + random.uniform(0, 0.5))
raise AssertionError("unreachable")Two details that matter:
Retry-After wins. We know when the bucket refills; your backoff curve is a guess. Only fall
back to exponential when the header is absent.
Add jitter. Without it, every worker that hit the limit at the same moment retries at the same moment, and you re-trigger the limit as a group.
Log the request id
Every response — success or failure — carries X-Request-Id, and it's repeated in error.request_id
on failures. Log it. With it we can find the exact request; without it, a support conversation
starts from a timestamp and a guess.
r = requests.get(url, headers=HEADERS)
log.info("api call", extra={"request_id": r.headers.get("X-Request-Id"), "status": r.status_code})Don't retry a 400 by changing nothing
An invalid_parameter will fail identically forever. The one 400 worth handling programmatically is
invalid_cursor: drop the cursor and restart that walk from the first page.
Empty is not an error
A filter matching nothing, a match with no odds, or a source you aren't entitled to all return 200
with data: []. Branch on emptiness, never on status, for those.