Rate limits¶
Each API key has a request budget, enforced with a token bucket.
| Default | |
|---|---|
| Rate | 120 requests per minute per key |
| Bucket capacity | 120 requests (equal to the per-minute rate) |
| Refill | Continuous: 2 tokens per second at the default rate (rate / 60) |
The operator can set a different rate for a specific key; ask if 120 per minute is not enough for a legitimate use.
How the bucket works¶
- Every authenticated request takes one token. A new bucket starts full.
- Tokens come back continuously, not in a burst at the top of the minute. At 120 per minute you can send 120 requests immediately, and after that about 2 per second sustained.
- When the bucket is empty the service answers
429 rate_limitedwith aRetry-Afterheader holding the number of whole seconds (at least 1) until one token is available. A rejected request does not take a token. - The limit is per key, not per IP address. Two scripts sharing one key share one bucket.
What counts:
| Request | Counts? |
|---|---|
Any /v1/... request with a valid key, including ones that return 400 or 404 |
Yes |
Every HTTP request to /mcp with a valid key. A client handshake and one tool call is several requests (initialize, tools list, the call itself). |
Yes |
Requests with a missing, wrong or scope-less key (401, 403) |
No, they are rejected before the bucket is checked |
GET /v1/health |
No (no key) |
Note
REST and MCP draw on the same bucket. An MCP client that calls many tools in a loop can starve a script that uses the same key. Use a separate key per integration.
Batch instead of looping¶
The cheapest request is the one you do not make.
| Instead of | Do this |
|---|---|
One GET /v1/latest per series |
One GET /v1/latest?ids=ru.key_rate,tr.policy_rate,tr.fx.usdtry with a comma-separated list. GET /v1/latest with no ids returns the newest value of every series in one response (693 series at the time of writing). |
| Downloading a long daily history and thinning it yourself | GET /v1/series/{id}?resample=month returns the last value of each period (week, month, quarter, year). |
| Calling several endpoints to build a product picture | GET /v1/products/{hs6} returns suppliers, prices, seasonality, restrictions and buyers in one call. |
| Polling to see whether anything changed | GET /v1/changes?since=... lists change events since a date; GET /v1/sources/freshness shows each source's last refresh. |
| Checking data freshness every few seconds | Most sources refresh daily or monthly (Coverage and freshness). Polling more often than once per run gains nothing. |
GET /v1/series/{id} takes exactly one id; a comma list there is read as a single, nonexistent id and returns an empty rows. For several series over a time range, use one call per series (MCP's get_series tool accepts a list).
Cache what does not change. A monthly series changes once a month; a response from this morning is still correct this afternoon. meta.sources[].fetched_at tells you whether anything could have changed.
Python backoff example¶
This helper follows the retry rules from Errors: it honours Retry-After on 429, backs off exponentially with jitter on 500, 504 and network errors, and never retries other 4xx responses.
import os
import random
import time
import requests
BASE = "https://trru.atapinar.com"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['TRRU_KEY']}"
def trru_get(path, params=None, max_attempts=5, timeout=60):
"""GET a TRRU endpoint and return the parsed JSON body (the full envelope)."""
for attempt in range(1, max_attempts + 1):
try:
resp = session.get(f"{BASE}{path}", params=params, timeout=timeout)
except (requests.ConnectionError, requests.Timeout):
resp = None # network problem: treat like a 5xx
if resp is not None:
if resp.ok:
return resp.json()
if resp.status_code == 429:
wait = int(resp.headers.get("Retry-After", "1"))
elif resp.status_code in (500, 504):
wait = min(2 ** attempt, 30) + random.random()
else: # 400, 401, 403, 404, 405: retrying will not help
err = resp.json().get("error", {})
raise RuntimeError(
f"{resp.status_code} {err.get('code')}: {err.get('message')} "
f"(request id {resp.headers.get('X-Request-Id')})"
)
else:
wait = min(2 ** attempt, 30) + random.random()
if attempt == max_attempts:
break
time.sleep(wait)
raise RuntimeError(f"giving up on {path} after {max_attempts} attempts")
latest = trru_get("/v1/latest", {"ids": "ru.key_rate,tr.policy_rate,tr.fx.usdtry"})
for row in latest["data"]["rows"]:
print(row["series_id"], row["value"], row["as_of"])
For a one-off script you do not need all of this; for anything that runs unattended (a daily bulletin, a dashboard) you do.
If you hit the limit regularly¶
- Find the loop: look for one-call-per-series patterns and replace them with
ids=lists orresample. - Cache responses for the lifetime of the data (daily series: a day; monthly: until the next month).
- Use one key per integration so that one noisy client does not slow the others.
- If the workload is legitimate and still does not fit, ask the operator for a higher rate on that key.