Skip to navigation

Rate limits

Fixed per-minute windows, the headers that report them, and how to back off

Limits are fixed one-minute windows, chosen by the credential you present.

CredentialLimitCounted per
Workspace key300 requests/minuteWorkspace (all of its keys share the budget)
Platform key3000 requests/minuteAccount

Two things follow from “counted per”:

  • Minting more keys does not raise a workspace’s limit. Every key on a workspace draws from the same 300.
  • Rotating a platform key does not reset its count. The budget is keyed to the account, not to the token.

Every request costs exactly one unit, reads included. A batch of 100 messages is one request against this limit — batching is the way to send more than 300 messages a minute on a workspace key.

The public error catalog (GET /errors and GET /errors/{code}) is the one exception: it takes no credential and is not counted against either budget, so following a type URL from an error never costs you a request.

The headers

Every response, not just a 429, reports where you stand:

RateLimit-Limit: 300
RateLimit-Remaining: 299
RateLimit-Reset: 1791359280
HeaderMeaning
RateLimit-LimitRequests allowed in the current window
RateLimit-RemainingRequests left in it
RateLimit-ResetUnix timestamp at which the window ends and the count returns to zero

Because the window is fixed rather than sliding, RateLimit-Remaining jumps back to the full limit at RateLimit-Reset — it does not refill gradually.

These are the request-rate limits only. Your plan allowance is a separate budget, reported on send responses as RRay-Quota-Limit, RRay-Quota-Remaining and RRay-Quota-Reset; see Limits & facts.

Being limited

Over the limit returns 429 rate_limit.exceeded with Retry-After in seconds:

HTTP/1.1 429 Too Many Requests
Retry-After: 24
RateLimit-Limit: 300
RateLimit-Remaining: 0
RateLimit-Reset: 1791359280
{
"type": "https://api.rray.app/errors/rate_limit.exceeded",
"title": "Rate limit exceeded",
"status": 429,
"code": "rate_limit.exceeded",
"category": "rate_limit",
"request_id": "df7b848fabd945889820b257e004c894",
"instance": "/v1/messages"
}

Honour Retry-After before retrying. It is never more than 60 seconds, since it is the remaining time in the current window.

One other code shares the 429 status: billing.daily_cap_reached, which also carries Retry-After — pointing at the next UTC midnight, not at the next minute. Branch on code, not on the status, so you do not sleep a request-rate interval when the real wait is hours.

Backing off

Retry on 429 and on 5xx. Never retry a 4xx other than 429: the request will not start succeeding.

import time
import httpx
RETRYABLE = {429, 500, 502, 503, 504}
def send_with_retry(client: httpx.Client, payload: dict, *, attempts: int = 5) -> httpx.Response:
for attempt in range(attempts):
response = client.post(
"/v1/messages",
json=payload,
# Same key on every attempt: a retry replays the original response
# instead of sending a second message.
headers={"Idempotency-Key": f"order-{payload['order_id']}-receipt"},
)
if response.status_code not in RETRYABLE:
return response
if attempt == attempts - 1:
return response
# Trust the server's own figure when it gives one; otherwise back off
# exponentially from one second.
wait = float(response.headers.get("Retry-After", 2**attempt))
time.sleep(wait)
raise AssertionError("unreachable")

Three things make this safe rather than merely polite:

  1. Reuse the Idempotency-Key across attempts. A timeout you retry without one can send the message twice. See Idempotency.
  2. Prefer Retry-After to your own guess. It is the exact remaining window.
  3. Cap the attempts. A send that keeps failing after a few tries needs a queue and an alert, not a longer loop.

Staying under the limit

  • Batch. POST /v1/messages/batch sends up to 100 messages for one unit of budget.
  • Do not poll every message. Read a message when something needs its status; use GET /v1/stats for aggregate numbers, which is one request for a whole window.
  • Watch RateLimit-Remaining in your own client. Slowing down at 10% remaining is cheaper than handling a 429.
  • Use a platform key for dashboards and a workspace key for sending. The account budget is ten times larger and keeps a reporting job from eating the send budget.