Skip to content

Rate limits ​

Every request to reportd counts against a rate limit for its class: sign-ins, signed-out requests, reads, writes, renders and the assistant. The studio and normal integrations never meet the defaults; a runaway script or agent does. A refused request gets 429 Too Many Requests with Retry-After.

The limits are on top of the sign-in throttle (5 failed sign-ins for a username, or 20 from one address, in 15 minutes), the MCP tool-call limit (mcp.rateLimitPerMinute) and the agents' edit limit (agents.maxOpsPerMinute). They are rates, not quotas: nothing counts renders or pages per day or month.

Classes ​

ClassRequestsCounted perDefault per minuteBurst
signinPOST /v1/login, /v1/login/mfa…, everything under /v1/auth/client address6020
publicany other request without a valid token, such as GET /l/{token}, or one with a missing or bad tokenclient address300100
readGET and HEAD, signed inuser or key1200300
writePOST, PUT, PATCH and DELETE, signed in, outside the two classes belowuser or key900120
renderPOST /v1/render, GET /v1/view/sessions/{id}/render, POST /v1/templates/{id}/validate, …/data, …/tests/run, POST /v1/jobs, POST /v1/sources/{name}/describeuser or key12030
assistantPOST /v1/templates/{id}/assistantuser or key3010
  • Not limited: /health, /livez and /readyz, and the studio's and viewer's own files.
  • Each class is a token bucket. It holds up to its burst and refills at its rate per minute, so a client can send a burst at once and then the rate.
  • A user has one bucket per class for all their sessions and tabs together. An API key or agent key has its own, so an agent doesn't use up the budget of the user it acts for.
  • Signed out, and with a bad token, a request counts by the client's address (an IPv6 address by its /64). Guessing tokens can't use up a real user's budget.
  • Work reportd does itself isn't counted: a batch job's runs, and the routes MCP tools and the assistant's tools call inside one request.

Config ​

yaml
limits:
  rate:                         # per minute and burst; perMinute 0 turns a class off
    signin:    {perMinute: 60,   burst: 20}
    public:    {perMinute: 300,  burst: 100}
    read:      {perMinute: 1200, burst: 300}
    write:     {perMinute: 900,  burst: 120}
    render:    {perMinute: 120,  burst: 30}
    assistant: {perMinute: 30,   burst: 10}
  • A class left out keeps its default, and so does a burst left out. limits.rate: {} keeps every default.
  • perMinute: 0 turns a class off.
  • reportd init-config writes the block with the defaults, for a server and for a workstation alike.
  • reportd refuses to start with a class it doesn't know, or a negative number.

On state.driver: file the limits live in each reportd's memory, as the sign-in throttle does: a restart forgets them, and with several reportd behind a load balancer, each counts its own requests. On postgres (state.md) every node counts in one bucket per class and key, one statement per request; while Postgres can't be reached, each node counts in its own memory and logs that once a minute.

Behind a proxy ​

Without security.trustedProxies, the client's address is the TCP peer. Behind a reverse proxy or load balancer, that is the proxy, and every signed-out client and every sign-in shares the proxy's buckets, as they share its sign-in throttle. Tell reportd which proxies to believe:

yaml
security:
  trustedProxies: [10.0.0.0/8, 127.0.0.1]   # CIDRs or single addresses; empty by default
  clientAddressHeader: X-Forwarded-For       # or Forwarded, or X-Real-IP
  • Only from a trusted peer does reportd read the header. It walks its addresses from right to left, skips the trusted proxies, and takes the first that isn't one. When every address is trusted, it takes the leftmost.
  • From any other peer, the header is ignored, so a client can't choose its own address.
  • Only the one configured header is read. When two are read, a proxy that cleans one of them still lets a client forge the other.
  • Once trustedProxies is set, X-Forwarded-Proto: https (which makes the session cookie Secure) counts only from a trusted peer. While it is empty, it counts from anyone, as before. With an https publicURL the cookie is Secure whatever the header says.
  • The same address is used everywhere: the rate limits, the sign-in throttle, MFA, the OIDC and SAML callbacks, and the access log's client field.
  • When requests carry one of the three headers and no proxy is trusted, reportd logs a warning once.

An API key's own limits ​

An administrator (user.manage) can give an API key or agent key its own rates, in place of the defaults, for read, write, render and assistant:

http
PATCH /v1/keys/k1a2b3c4d5e6
Content-Type: application/json

{"rateLimits": {"render": {"perMinute": 600, "burst": 60}, "read": {"perMinute": 0}}}
  • rateLimits replaces the key's limits; {} goes back to the defaults. POST /v1/keys accepts the same field.
  • A class left out keeps the default, and so does a burst left out. perMinute: 0 is no limit for that key and class.
  • GET /v1/keys shows each key's limits. Each change is audited as key.rate-limits <id>.
  • A key's limits apply to that key only. An agent key's don't change the limits of the user it acts for. Users all share the defaults.

Responses ​

A refused request:

http
HTTP/1.1 429 Too Many Requests
Retry-After: 4
RateLimit-Policy: "render";q=120;w=60
RateLimit: "render";r=0;t=4
Content-Type: application/json

{"error": "rate-limited", "class": "render", "message": "too many requests; retry in 4s", "retryAfter": 4}
  • Retry-After is in whole seconds, rounded up.
  • Every response a class limits carries RateLimit-Policy and RateLimit, the structured fields of the IETF httpapi draft draft-ietf-httpapi-ratelimit-headers-11, which may still change. q is the class's rate per window and w the window (60 s). r is the requests left in the bucket now. While r is above 0, t is the seconds until the bucket is full again; at 0, the seconds until the next request may pass, which on a 429 is Retry-After.
  • The MCP tool-call limit and the agents' edit limit answer the same 429 body, without class.
  • A 429 is logged at debug level, at most once per bucket a minute.

The studio and the viewer wait out a 429 on a read, a render or a preview once, when Retry-After is at most 10 seconds. Otherwise they say "Too many requests, try again in N s". A 429 never shows the connection-lost overlay. A save the limit refused is tried again after the wait.

Memory ​

  • Each minute a sweep drops the buckets that have refilled; a full bucket is the same as none.
  • Each class keeps at most 50,000 signed-out address buckets. Past that, a new address shares one overflow bucket until the sweep frees room, and reportd logs a warning once a minute while it lasts. A flood from many addresses then limits itself, not the users who are signed in.

In front of reportd ​

A reverse proxy, load balancer or WAF can add limits of its own, such as per connection or per country, before requests reach reportd. reportd's limits still apply behind it, by user and key as well as by address.