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.
bulk_quota_exceeded429No. 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_exceeded429No. Legacy trials only: subscribe to continue.
trial_endpoint_quota_exceeded429No — not on that endpoint. Legacy trials only: its per-endpoint ceiling is used up, but other endpoints still work.
sandbox_quota_exceeded429No. Sandbox keys only: the 2,000 requests are used and never reset.
sample_quota_exceeded429No, 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_scope403No. Sandbox keys and the public sample key only: the request is outside the sample. Not counted.
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

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.

On this page