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. |
trial_quota_exceeded | 429 | No. Subscribe to continue. |
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
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.