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
429with aRetry-Afterheader when you exceed them. A resilient client reads the headers to stay ahead of the limits, and when it does hit a429, it waits exactlyRetry-Afterseconds 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.
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:
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 a429, 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 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=200on 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
- Pull match data efficiently — big pages and cursors keep call counts down.
- Analyse stats in Python — wrap the DataFrame fetches in this client.
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.