Skip to content

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_limited with a Retry-After header 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

  1. Find the loop: look for one-call-per-series patterns and replace them with ids= lists or resample.
  2. Cache responses for the lifetime of the data (daily series: a day; monthly: until the next month).
  3. Use one key per integration so that one noisy client does not slow the others.
  4. If the workload is legitimate and still does not fit, ask the operator for a higher rate on that key.