How-to

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

codeStatusRetry?
rate_limited429Yes — honour Retry-After, usually a second or two.
quota_exceeded429No. Out of requests until X-Quota-Reset. Retrying burns nothing but achieves nothing.
trial_quota_exceeded429No. Subscribe to continue.
internal_error500Yes — with exponential backoff.
unavailable503Yes — with backoff.
invalid_parameter400No — fix the request.
invalid_cursor400No — restart pagination from the first page.
not_found / unknown_game404No.
missing_api_key / invalid_api_key401No — 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.

On this page