guide

"Handle CS2 API rate limits and build a resilient client"

Jul 10, 2026

Bottom line: The CS2 API enforces two independent limits: a per-second rate bucket and a monthly request quota (10,000 on the standard plan). Both surface their state in response headers on every call, and both return a 429 with a Retry-After header when you exceed them. A resilient client reads the headers to stay ahead of the limits, and when it does hit a 429, it waits exactly Retry-After seconds and retries. The error body tells you which limit you hit. Built against the EsportsOdds CS2 data API.

Two limits, not one

It's worth separating these in your head, because they behave differently and you handle them differently.

There are two separate caps: a per-second rate limit implemented as a token bucket, and a monthly request quota. Either one can return a 429.

The rate limit is a token bucket — it smooths bursts over time and refills continuously, so a brief spike is fine but a sustained flood isn't. The monthly quota is your plan's total request budget, which resets on a fixed date. Hit either and the response is a 429.

Read the headers on every response

You don't have to guess where you stand. Every metered response carries your current numbers:

The headers to watch: X-RateLimit-Remaining for this second, X-Quota-Remaining for the month, X-Quota-Reset for when the month rolls over, and Retry-After on a 429.

  • X-RateLimit-Limit / X-RateLimit-Remaining — your burst capacity and what's left of it right now.
  • X-Quota-Limit / X-Quota-Remaining — your monthly allowance and what's left of it.
  • X-Quota-Reset — an RFC-3339 timestamp for when the monthly quota rolls over.
  • Retry-After — present on a 429, the whole number of seconds to wait.

One thing to note: there's a X-Quota-Reset but no rate-limit reset header, because the rate bucket refills continuously — for the per-second limit, Retry-After is your signal, not a reset time.

def log_budget(resp):
    print(
        f"rate {resp.headers.get('X-RateLimit-Remaining')}/{resp.headers.get('X-RateLimit-Limit')}  "
        f"quota {resp.headers.get('X-Quota-Remaining')}/{resp.headers.get('X-Quota-Limit')}  "
        f"(resets {resp.headers.get('X-Quota-Reset')})"
    )

Handle a 429 by honouring Retry-After

When you do get a 429, don't invent your own backoff — the server already told you exactly how long to wait. Read Retry-After, sleep that long, and retry.

The resilient request loop: make the call; if it returns 429, sleep for Retry-After seconds and retry; otherwise return the data.

The 429 body is a flat {"error": "…"} that names which limit tripped — "rate limit exceeded" or "monthly quota exceeded". That distinction matters: a rate-limit 429 clears in seconds and a retry is right; a quota 429 won't clear until your reset date, so retrying is pointless and you should surface it instead.

Here's a complete, reusable client that puts it together:

import os
import time
import requests

BASE = "https://api.esportsodds.gg"


class EsportsOddsClient:
    def __init__(self, api_key: str, max_retries: int = 4):
        self.session = requests.Session()
        self.session.headers["Authorization"] = f"Bearer {api_key}"
        self.max_retries = max_retries

    def get(self, path: str, **params) -> dict:
        for attempt in range(self.max_retries + 1):
            resp = self.session.get(f"{BASE}{path}", params=params, timeout=15)

            if resp.status_code != 429:
                resp.raise_for_status()
                return resp.json()

            # We hit a limit. Which one?
            reason = resp.json().get("error", "")
            if "quota" in reason:
                # Monthly quota is exhausted — retrying won't help until reset.
                raise RuntimeError(
                    f"monthly quota exceeded; resets {resp.headers.get('X-Quota-Reset')}"
                )

            # Rate limit: wait exactly as long as we're told, then retry.
            wait = int(resp.headers.get("Retry-After", "1"))
            time.sleep(wait)

        raise RuntimeError(f"still rate-limited after {self.max_retries} retries")


client = EsportsOddsClient(os.environ["ESPORTSODDS_API_KEY"])
matches = client.get("/v1/cs2/matches", status="live")["data"]

This one class handles both failure modes correctly: it retries transient rate-limit 429s using the server's own timing, and it fails fast and loud on a quota 429 where a retry would just burn time.

Stay ahead of the limits

Retrying is the safety net; the better move is not tripping the limit in the first place. A few habits help:

  • Reuse one Session. It keeps connections warm and is where your auth header lives.
  • Request big pages. limit=200 on list endpoints means far fewer calls for the same data — see the pagination guide.
  • Cache what doesn't change often. Team and tournament metadata rarely moves; don't re-fetch it every loop.
  • Watch X-Quota-Remaining. If a nightly job is meant to use 500 requests and the header says you've burned 4,000, something's looping.

Next steps

Read the headers, honour Retry-After, and tell a rate-limit 429 apart from a quota one. The endpoint reference documents the headers on every response.