Rate limits
How the Axuz API request limits work, per axp_ personal token, per team mutation and per IP, which headers come with responses and how to react with exponential backoff and jitter.
The API limits requests to protect the service from abuse and runaway loops. There is more than one limit, at different layers, and each answers 429 Too Many Requests with a Retry-After header. This page describes the ones the code implements today.
Per personal token limit (axp_)
Each personal token has its own per-minute ceiling, set at creation (rate_limit_per_min, from 1 to 1000, default 60). It cannot be changed afterwards: to change it, revoke the token and create another one (see the quickstart). The ceiling applies to the token, not to the account: two tokens of the same user have separate buckets.
The counter is a fixed window per wall-clock minute: the request’s bucket is the current UTC minute (key token + minute), and it resets at the minute boundary. So you can spend the whole ceiling in the last seconds of one minute and again right after the turn.
The limit only applies to callers arriving by token. Browser requests with a session (cookie) do not go through this bucket.
Headers
When the limiter is active, every response to a token call, allowed or denied, carries:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | The token’s per-minute ceiling (for example 60). |
X-RateLimit-Remaining | How many calls still fit in this minute (never negative). |
X-RateLimit-Reset | Unix second (epoch, in seconds) when the next minute starts and the bucket resets. |
Retry-After | On 429 only: seconds until the next window, between 1 and 60. |
The 429 also carries a JSON body:
{ "error": "rate_limited", "limit": 60, "count": 61, "retry_after": 12 }
count is the counter value after this request; it can exceed limit, because denied requests also count within the minute.
Fail-open or fail-closed
What happens if Redis is down at request time depends on a server setting (RATE_LIMIT_FAIL_CLOSED):
- fail-open (the code’s default): the request goes through and the server logs a warning. You lose the ceiling for a moment, never availability;
- fail-closed: the request is refused with
503and{"error": "rate_limiter_unavailable"}, withRetry-After: 60.
The fleet’s deployment descriptor turns fail-closed on by default, so in hosted environments treat a 503 with Retry-After as “try again shortly”, exactly like a 429.
Team mutation limit
The team routes of the workspace service (/teams/{teamId}/...) limit writes: POST, PATCH, PUT and DELETE. GET does not count. The bucket is per (user, team) pair, with a default of 60 mutations per minute, and it is a token bucket that refills continuously (60 per minute = 1 per second) rather than a fixed window. The 429 response is:
{ "error": "rate_limited", "retry_after": 3 }
with Retry-After (seconds, rounded up, minimum 1) and X-RateLimit-Limit carrying the bucket capacity. There is no X-RateLimit-Remaining or Reset on this limit. The same fail-open/fail-closed switch exists (MUTATION_RATE_LIMIT_FAIL_CLOSED), with 503 rate_limiter_unavailable in the second case.
In practice: bulk imports, scripts that create hundreds of tasks and load tests hit this limit before any other. Space writes out (about one per second per team) instead of counting on bursts.
Other limits you may meet
- Login and authentication attempts. The authentication service limits login attempts, e-mail verification and code issuance, and blocks abusive IPs. The response is
429withRetry-After; on an IP block the body is{"error": "ip_blocked", "retry_after_seconds": N}. The numbers are tunable and may change; always honourRetry-After. - OAuth endpoints (
/oauth/token,/oauth/revoke,/oauth/introspect):429withRetry-Afteranderror: "rate_limited"in the OAuth error format. - AI assistant (
POST /me/ai/chat): per-user limit;429withRetry-After. - File uploads: there is a per-identity ceiling and a generous per-IP ceiling, against flooding, in the files service.
- Invitations: a per-minute limit (default 20) on sending team invitations.
- Team keys (
axt_..., part of integrations, in progress): they share the personal token’s design, withX-RateLimit-*andRetry-After. The public contract of that surface is still being completed.
How to react
- Honour
Retry-After. It is the most reliable signal. Wait that long (plus a small random extra) before retrying. - Without
Retry-After, use exponential backoff with jitter. Start at 0.5 s, doubling up to a cap (for example 30 s), and draw the actual wait between 0 and the computed value (full jitter). Jitter keeps many clients from waking together and hitting the limit again. - Slow down before you hit it. If
X-RateLimit-Remainingreaches0, sleep untilX-RateLimit-Resetinstead of provoking the429. - Cap the attempts. Stop after a few retries (for example 5) and return the error to the caller.
- Only retry what is safe.
GET,PUTandDELETEcan be retried; aPOSTthat creates something only with anIdempotency-Keyor after reconciling state. See Errors. - Do not parallelise to “compensate”. More connections drain the same bucket faster.
Node
// node 20+, ESM. fetch with safe retries for 429/503.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function axuzFetch(url, init = {}, { retries = 5 } = {}) {
const headers = { Authorization: `Bearer ${process.env.AXUZ_TOKEN}`, ...init.headers };
const method = (init.method ?? 'GET').toUpperCase();
const safe = ['GET', 'HEAD', 'PUT', 'DELETE'].includes(method) || headers['Idempotency-Key'];
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, { ...init, headers });
// Slow down before hitting the ceiling.
const remaining = Number(res.headers.get('x-ratelimit-remaining'));
const reset = Number(res.headers.get('x-ratelimit-reset'));
if (res.ok && res.headers.has('x-ratelimit-remaining') && remaining === 0 && reset) {
await sleep(Math.max(0, reset * 1000 - Date.now()) + 50);
}
const retryable = res.status === 429 || res.status === 503;
if (!retryable || !safe || attempt >= retries) return res;
const retryAfter = Number(res.headers.get('retry-after'));
const waitMs = retryAfter > 0
? retryAfter * 1000 + Math.random() * 500 // honour the server
: Math.random() * Math.min(30_000, 500 * 2 ** attempt); // full jitter
await sleep(waitMs);
}
}
const res = await axuzFetch(`${process.env.AXUZ_API}/me/summary`);
console.log(res.status);
Python
import os
import random
import time
import urllib.error
import urllib.request
def axuz_get(url, retries=5):
"""GET with retries for 429/503: honours Retry-After, otherwise full jitter."""
headers = {"Authorization": "Bearer " + os.environ["AXUZ_TOKEN"]}
for attempt in range(retries + 1):
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=30) as res:
remaining = res.headers.get("X-RateLimit-Remaining")
reset = res.headers.get("X-RateLimit-Reset")
if remaining == "0" and reset:
time.sleep(max(0.0, int(reset) - time.time()) + 0.05)
return res.read()
except urllib.error.HTTPError as e:
if e.code not in (429, 503) or attempt == retries:
raise
retry_after = e.headers.get("Retry-After")
if retry_after and retry_after.isdigit() and int(retry_after) > 0:
wait = int(retry_after) + random.uniform(0, 0.5)
else:
wait = random.uniform(0, min(30.0, 0.5 * 2**attempt))
time.sleep(wait)
print(axuz_get(os.environ["AXUZ_API"] + "/me/summary")[:200])
What not to do
- Do not run several processes with the same token expecting to multiply throughput: the ceiling belongs to the token.
- Do not ignore
Retry-Afterand retry in a tight loop: every denied attempt still counts in the bucket. - Do not treat
429as fatal in long jobs: it is a normal flow-control signal. - Do not create extra tokens just to dodge the ceiling. If legitimate use needs more than 1000 requests per minute, the design (batching and aggregation, such as
GET /me/summary, instead of one call per item) is the way.