guide

Handle CS2 API rate limits and build a resilient client

Jul 10, 20267 min read

Bottom line: The CS2 API enforces two independent limits: a per-second rate bucket and a request quota (20,000 per month on the standard plan; 1,000 across the whole 7-day trial, not per month). 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

Keep these two apart in your head. 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 an 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')})"
    )

There's a staging detail worth knowing: the X-RateLimit-* pair is set the moment your key authenticates, but the X-Quota-* trio is only set after the rate-limit check passes. So a 429 that tripped the per-second bucket carries the X-RateLimit-* headers and no X-Quota-* headers at all. Read the quota headers defensively (with .get, as above), not as guaranteed on every response.

Header visibility by stage: once auth passes the X-RateLimit headers are set, once the rate check passes the X-Quota headers are set, then the quota check either allows the request or returns a 429; a rate-limited 429 therefore carries no X-Quota headers.

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 is the whole game. A rate-limit 429 clears in seconds, so sleeping Retry-After and retrying is right. A quota 429 won't clear until your reset date, and its Retry-After is the seconds remaining until that date, which can run to days or weeks. Blindly sleeping it would park your process for a fortnight, so read the body first and surface a quota exhaustion instead of waiting it out.

On a 429, read the body first: rate limit exceeded clears in seconds so sleep Retry-After and retry, but monthly quota exceeded will not clear until the reset date, so stop rather than sleep a Retry-After that can be weeks long.

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. Every line it pulls back is our own derived data; the methodology covers how the odds and ratings are computed.

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.

Budgeting a polling loop

Most integrations against this API are a poll: fetch the fixtures, fetch the market line, repeat. The single biggest thing that decides whether 20,000 requests a month feels roomy or tight is whether you poll both of those at the same interval. You shouldn't, because they don't change at the same rate.

The derived market line moves constantly — in a recent week the median gap between two successive line writes for a given match was about 55 seconds, and the 90th percentile was around four and a half minutes. The fixtures list is far more static: match rows changed roughly six times an hour across the whole schedule, against roughly 650 line writes an hour. That's a difference of about two orders of magnitude, so polling fixtures every five minutes buys you almost nothing and costs you exactly as much as polling odds does.

Budget them separately:

LoopSensible intervalRequests / month
Odds snapshot (/v1/cs2/odds, bulk by default)every 5 min~8,600
Fixtures list (/v1/cs2/matches)hourly~720
Total~9,400 of 20,000

That leaves roughly half the plan for everything else — backfills, stats calls, retries, a second environment. Poll both loops at five minutes instead and the same integration costs about 17,300 requests, or 86% of the plan, for one extra fixtures refresh per hour that you almost never needed.

Two smaller levers matter once you're inside that budget. The odds endpoint returns a bulk snapshot by default, so one call covers every match with a current line — resist the urge to loop per match, which turns one request into dozens. And if you're only tracking a handful of matches, poll on their schedule rather than round the clock; there is no reason to spend requests at 04 on a region that isn't playing.

The trial is metered the same way but with a much smaller budget: 1,000 requests across the whole seven days, not per month. That is sized for evaluating shapes and wiring up a client, not for running a production loop — a five-minute poll would spend it in under a day. If you want to run a real loop during evaluation, end the trial early from the plan page and start the paid month.

Next steps

Read the headers, honour Retry-After, and above all tell a rate-limit 429 apart from a quota one before you decide to wait. Do that and your call volume can climb without a single request lost to a limit you could have seen coming.

Full API reference: Budget your requests in the developer docs.