Rate limits
The per-endpoint request budgets, the 429 contract and its Retry-After header, and how to back off without hammering
Amdahl enforces rate limits at several layers so that bursty traffic from one caller cannot starve the platform. This page documents what is live today and the client-side patterns that keep an integration inside them.
Current state
Global per-caller limiter
Every request to the platform passes through one limiter applied at the HTTP edge. It counts each call against the budget of its cost class, so cheap calls do not spend the budget of costly ones. Each class is a separate budget per caller, over a 60-second window:
| Class | Per caller, per minute | What counts in it |
|---|---|---|
protocol | 600 | MCP session traffic that runs no operation: initialize, ping, every notifications/* message, tools/list, resources/list, resources/templates/list, resources/read, prompts/list, prompts/get, and the GET (event stream) and DELETE (session close) requests on /mcp |
read | 300 | Every REST GET, POST /search/query, and these MCP tools: search_customers, list_search_fields, get_setup_status, list_connectors, list_connections, get_connection, get_connection_status, list_connection_syncs, get_connection_summary, get_chat_run, list_agents, get_agent, list_routines, get_routine, get_eval_run, get_eval_gate, get_eval_evidence, get_eval_drafts, get_eval_improvement, get_eval_feedback, get_eval_adoption, list_eval_runs, list_evals, get_eval, get_eval_trend, get_research, get_message_batch |
expensive | 30 | Calls that start model work or a connector flow. MCP: optimize_message, optimize_message_batch, run_eval, start_research, start_chat, run_routine_now, connect_source. REST: POST /messages/optimize, POST /messages/optimize/batch, POST /evals/run, POST /research, POST /chat, POST /routines/:id/run-now, POST /connections |
standard | 60 | Everything else: other writes (create, update, delete, respond, cancel), OAuth and key-management requests, and any MCP call the table above does not name |
In local development the four limits are 3,000, 1,500, 150 and 500.
A caller is the credential the request sends. Requests with the same API key, OAuth token or console session share one set of budgets, from any address. Two different credentials get two sets, even from the same address. A request with no credential (no Authorization or X-API-Key header) is counted against its client IP address.
On /mcp the class comes from the JSON-RPC body: the method, and for tools/call the tool name. A call on a retired tool name (such as messages) is classed by its arguments.action, the same as the tool that action now has. A JSON-RPC batch counts each item in its own class, so a batch of 31 optimize_message calls is refused as a whole. A tool name the server does not have, a retired name with an action no tool runs, or a body that is not a JSON-RPC request, counts as standard.
The classes are independent. Reaching the expensive limit does not stop reads or session traffic, and an agent's session traffic does not spend the budget its tool calls need. Several agents sharing one key still share each budget: ten agents that each start four Chats in one minute reach the expensive limit.
The expensive limit is a per-minute rate. The monthly verb quotas and the warehouse-read budget below are separate and still apply.
A second limit sits around the per-caller ones, counted per client IP address over every request from that address, whatever credential it sends:
- 300 requests per minute per client IP for every request that holds a
read,expensiveorstandardcall (2,000 in local development). - 1,200 requests per minute per client IP for requests that hold only
protocolcalls (6,000 in local development). These do not count toward the 300.
Several keys used from one address share these ceilings, so a server that runs many keys from one egress IP can hit 300 before any single key hits its own limit. Protocol traffic has its own ceiling so that a group of agents behind one office address is not refused before any of them does work: one agent session sends about 35 protocol requests a minute. A 429 from a ceiling has the same body and Retry-After header as a per-caller 429; its X-RateLimit-Limit reads 300 or 1200.
The limiter is mounted in front of every route on the API host, not only /api/platform/v1/*. The REST API, the MCP endpoint (/mcp), the OAuth endpoints (/oauth/*) and the key-management endpoints (/api/mcp/api-keys) all draw on the same per-caller budgets. A REST call and the MCP action that does the same thing share one budget: 20 POST /messages/optimize calls and 10 optimize_message tool calls in the same minute with one key use all 30.
Every budget is counted per server replica, in process. Amdahl runs more than one replica behind the load balancer, so the effective limit a single client sees can be higher than the table says and is not stable enough to plan against. Treat the table as the number to stay under, never as headroom to spend.
OAuth dynamic client registration
Dynamic client registration under RFC 7591 has its own tighter bucket:
- 5 registrations per minute per IP at
POST /oauth/register
This is deliberate. Client registration is a write operation that provisions long-lived credentials, so the protection is stricter than read traffic.
Chat turn budgets
Chat runs have one soft cap that functions as a turn-based rate limit:
- The depth tier's turn budget: 10 turns for
quick, 75 fordeep, and the agent profile's own budget forstandard(50 for the Master agent a Chat runs by default). When exhausted the run pauses with acontinue_or_finishquestion rather than ending outright; you decide whether to grant more turns or wrap up.
See Chat: depth for the tiers and Agents: resume for answering the pause.
Verb quotas
The verb families (search / evals / messages) can carry a monthly hard cap per workspace. Exhausting one returns quota_exceeded (429) with the family, limit, and usage in details; the cap resets at the start of the next month. Search and evals are uncapped unless an operator sets one. The Message Optimizer (messages) is capped at 1,000 calls a month by default.
Per-user warehouse-read budget
The global per-caller budgets are not the only limit you are spending. Reads against your warehouse carry their own, much tighter budget underneath it:
- 10 warehouse reads per minute per user
It is a fixed 60-second window, counted per user rather than per IP. The unit is a warehouse read, not an HTTP request, and one call can spend several:
| Call | Reads it can spend |
|---|---|
POST /search/query, mode: "filter" | 1 |
POST /search/query, mode: "fuzzy" (the default for a plain-language ask) | Up to 2 per planned sub-question (the query, plus one repair attempt when it fails), so up to 6 at the default of 3 sub-questions, plus a freshness probe that is cached per workspace |
coverage: true, hydrate: true on any lane | Additional reads for the gap probe and the hydration lookup |
GET /search/overview | Up to 3, one per surface |
A read whose SQL the gate refuses still spends one. An identical query served from the short-lived result cache spends none. The semantic and lexical lanes read a separate index and spend none for the match itself; their coverage and hydrate extras do.
Like the global limiter, the budget is counted per server replica, in process. Amdahl runs more than one replica, so the ceiling a single client observes can be a multiple of ten and is not stable enough to plan against. Treat ten reads a minute as the number to stay under, never as headroom to spend.
How a refusal reaches you depends on the lane.
On the filter lane, a refused read returns HTTP 429 with the standard JSON envelope, a Retry-After header carrying the seconds until the window rolls, and X-RateLimit-* headers scoped to this budget:
{
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded. Maximum 10 queries per minute. Try again in 37 seconds.",
"details": { "retry_after_seconds": 37, "limit": 10, "window_seconds": 60 }
}
}On the fuzzy lane, a refused read is not a 429. The lane treats it like any failed SQL: it asks its SQL writer for a repair, tries once more (which spends another read if the budget has room), and then returns HTTP 200 with success: true and detail.internal.status: "failed", with the throttle message in the note. A client that only checks for 429 will read this as "the question could not be answered" rather than "slow down". If you see detail.internal.status: "failed" during a burst, back off before rewording the question.
Read X-RateLimit-Limit on a successful warehouse-reading response to discover this budget rather than discovering it by tripping it. On those responses the X-RateLimit-* headers describe this budget (10) rather than the global one (300, the read class limit, which is what a non-warehouse read like GET /search/fields reports). Same header names, same Unix-seconds X-RateLimit-Reset.
A 429 here means wait and retry; it does not mean your query is wrong. Distinguish it from a 400 (invalid_sql: the SQL must change, retrying is pointless) and a 500 (query_failed: the warehouse faulted, safe to retry).
Message Optimizer: per-workspace limits
POST /messages/optimize and the optimize_message tool have limits per workspace, separate from the per-caller expensive budget above. They count every credential of the workspace and the console together. An optimize usually takes 1 to 2 minutes, so a caller inside its 30 a minute could otherwise hold many runs open at once.
- 3 running at once per workspace.
- 12 started in any 60 seconds per workspace.
- A limit across all workspaces. When it is full, a free place goes to the workspace with the fewest calls running.
A call over a limit waits in line for up to 15 seconds, then is refused. A call from the MCP optimize_message tool waits for up to 2 minutes. Every refusal is HTTP 429 with the JSON envelope, error.code: "rate_limited", and error.details.reason set to workspace_in_flight_limit, workspace_rate_limit or optimizer_busy. Each carries Retry-After and error.details.retry_after_seconds. The value is exact for workspace_rate_limit. For the other two it is an estimate of when a running call finishes (5 to 75 seconds), so a retry can be refused again. See Message Optimizer: running several at once.
One more rate_limited refusal is not a limit. When the Optimizer fails 5 requests in a row inside one minute, Amdahl stops sending calls to it for 30 seconds. A call in that time is refused immediately with error.details.reason: "optimizer_unavailable", and Retry-After is the time that is left. See When the Optimizer is down.
Message Optimizer: the batch lane
The limits above are for single calls, where somebody is waiting for the answer. A long list of drafts sent through them is mostly 429s. For that, send a batch: POST /messages/optimize/batch, or the optimize_message_batch tool.
- A batch is accepted at once and never refused as busy. It returns
202with abatch_id. None of the three limits above applies to the submit, and none of its drafts is refused withworkspace_in_flight_limit,workspace_rate_limit,optimizer_busyoroptimizer_unavailable. The drafts wait in a queue, and they stay in it while the Optimizer is down. - Single calls go first. Batch items run only while the Optimizer has capacity that single calls are not using, and part of the capacity is never given to batches, so a single call still starts at once while a long batch runs.
- Batch items do not count toward the workspace's 3 at once or 12 a minute. They count only toward the limit across all workspaces.
- Workspaces take turns. When several workspaces have batches queued, the next free place goes to the workspace with the fewest batch items running.
What can refuse a batch is not capacity: a body that does not validate (400), a batch larger than what is left of the monthly cap (429 quota_exceeded), more than 5,000 batch items queued for one workspace (429 rate_limited with details.reason: "batch_backlog_limit" and no Retry-After), and an idempotency_key reused for a different body (409 conflict). The submit itself is one call in the expensive class, and each status read is one call in the read class.
Read progress, an estimated finish time and each draft's result from GET /messages/optimize/batches/{id}. See Message Optimizer: batches.
Per-key limits
The global limiter above counts each API key or token on its own, by cost class. There is no separate per-key limit beyond it: every other budget on this page is per IP, per user or per workspace. Build the header reading and backoff below into your client regardless.
Response headers
Every response from the API host carries rate-limit headers that let you monitor your budget without a separate bookkeeping layer:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Total requests permitted in the current window |
X-RateLimit-Remaining | Requests still available in the current window |
X-RateLimit-Reset | Unix timestamp (seconds) when the window resets |
The global limiter's values describe the cost class the request was counted in, so X-RateLimit-Limit reads 600, 300, 30 or 60. Read these on every response, not just on errors. If X-RateLimit-Remaining is close to zero, slow down before you hit a 429. Note these are the legacy X--prefixed names. The IETF draft RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset headers are not sent, so a client library that reads only the draft names will see no budget information.
429 response shape
The global limiter returns HTTP 429 with a plain-text body, not JSON:
Too many requests, please try again later.Content-Type is text/html; charset=utf-8. Branch on the HTTP status, never on the body — parsing it as JSON will throw. The Retry-After header (seconds) is set on every 429 and is the value to back off on.
Some endpoints enforce their own narrower limits and DO return the standard JSON error envelope — for example the eval export downloads:
{
"error": {
"code": "rate_limited",
"message": "Export rate-limited. Try again in 42 seconds.",
"details": {
"retry_at": "2026-08-10T14:22:31Z",
"retry_after_seconds": 42
}
}
}A robust client reads Retry-After first, then falls back to details.retry_after_seconds if the body happens to be JSON.
Backoff strategy
When you receive a 429, back off and retry. The recommended pattern is exponential backoff with full jitter, capped at 60 seconds:
async function withBackoff<T>(fn: () => Promise<T>, maxAttempts = 5): Promise<T> {
let attempt = 0
while (true) {
try {
return await fn()
} catch (err: any) {
if (err.status !== 429 || attempt >= maxAttempts - 1) throw err
const baseMs = Math.min(60_000, 1000 * 2 ** attempt)
const jitterMs = Math.random() * baseMs
await new Promise(r => setTimeout(r, jitterMs))
attempt++
}
}
}Three rules:
- Honor
Retry-Afterfirst. If the header is present, sleep at least that many seconds before retrying. - Never retry faster than 1 second. Tight retry loops make the problem worse.
- Cap at 60 seconds. Beyond that, surface the error to the caller rather than hiding a long stall.
Best practices
- Aggregate where possible.
POST /search/querycan filter, group, and aggregate in a single call (group_by+metrics); avoid making ten requests for data one aggregation returns. - Cache surface metadata. The tool catalog, scope tables, and
GET /search/fieldsvocabulary change on the order of weeks, not seconds. Cache them on your side and refresh daily, not per-request. - Do not poll faster than 1/sec. When polling a Chat run, prefer
read_url?wait_ms=30000— one long-poll request per 30 seconds instead of thirty short ones — or subscribe to the run'sstream_url(SSE) for sub-second updates. See Chat: watching a run. - Spread start bursts. Starting hundreds of Chats or firing hundreds of routine run-nows in seconds will tip you into throttling. Spread them over a minute or enqueue them on your side.
- Key your retries by idempotency. If you retry a
POST, make sure the server-side call is idempotent or carries a dedupe key, so a successful-but-timed-out first attempt plus a successful retry do not create two records.
See also
- API reference: errors for the full error envelope and every code
- Chat for the run-level budget mechanics