Docs

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, what is on the roadmap, and the client-side patterns you should adopt now so your integration behaves correctly once per-key throttles ship.

Current state

Global per-IP limiter

Every request to the platform passes through a single global limiter applied at the HTTP edge. The defaults are:

  • 60 requests per minute per IP for production traffic on /api/platform/v1/*
  • 500 requests per minute per IP in local development

The limiter is shared across all endpoints under the platform API. A client making 40 POST /search/query calls and 20 GET /chats calls in the same minute exhausts the same 60-request budget.

The budget is counted per server replica, in process. Amdahl runs more than one replica behind the load balancer, so the effective ceiling a single client sees can be higher than 60 and is not stable enough to plan against — treat 60/min 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 (roughly 10 / 50 / 75 turns for quick / standard / deep). When exhausted the run pauses with a continue_or_finish question 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) can carry an operator-set 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. Unset caps cost nothing — most workspaces never see this code.

Per-endpoint query budget

The global per-IP budget is not the only limit you are spending. Warehouse-reading endpoints carry their own, much tighter budget underneath it:

  • 10 queries per minute per user for POST /search/query and POST /data/query

This is a fixed 60-second window, counted per user rather than per IP. It is roughly six times tighter than the global 60/min, so pacing against 60/min will exhaust it: a client issuing 43 searches per minute is spending a budget of ten.

Like the global limiter, it is counted per server replica, in process. Amdahl runs more than one replica, so the ceiling a single client actually observes can be a multiple of ten and is not stable enough to plan against — treat 10/min as the number to stay under, never as headroom to spend. The corollary is that you may exceed ten in a minute without seeing a 429 and still be one unlucky request-routing away from one.

Exceeding it returns HTTP 429 with the standard JSON envelope, a Retry-After header carrying the exact seconds until the window rolls, and the X-RateLimit-* headers below scoped to this budget rather than the account budget:

json
{
  "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 }
  }
}

Read X-RateLimit-Limit on a successful query response to discover this budget rather than discovering it by tripping it. On these endpoints the X-RateLimit-* headers describe this budget and not the account budget — they report 10, not the 60 a non-warehouse read like GET /search/fields reports — because on a warehouse read the tighter one is what you must pace against. Same header names and the same Unix-seconds X-RateLimit-Reset format as everywhere else, so no special handling is needed.

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).

One quirk worth knowing when you pace: an identical repeated query is served from a short-lived result cache and does not spend budget. Only distinct queries draw down the ten. Do not use that as a pacing strategy — it is an implementation detail, not a contract — but it does explain why a loop re-running one query never trips the limit while a loop of ten different ones will.

Per-key and per-tool limits (roadmap)

Per-API-key rate limits are planned but not yet enforced (the per-endpoint query budget above is per-user and already live). When they land:

  • Limits will be scoped to the API key, not the IP, so shared infrastructure stops being a noisy neighbor.
  • Read-heavy operations (search.query, search.fields, Chat reads) will get higher budgets than operations that start work (chat.start, routines.run_now).
  • Burst allowances will let callers spike briefly before sustained throttling kicks in.

Until that ships, treat the 60-req-per-minute global limit as your effective ceiling and build in the headers and backoff logic below so your code keeps working when the tighter per-key limits arrive.

Response headers

Every response from the platform API carries rate-limit headers that let you monitor your budget without a separate bookkeeping layer:

HeaderMeaning
X-RateLimit-LimitTotal requests permitted in the current window
X-RateLimit-RemainingRequests still available in the current window
X-RateLimit-ResetUnix timestamp (seconds) when the window resets

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 per-IP limiter returns HTTP 429 with a plain-text body, not JSON:

code
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:

json
{
  "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:

typescript
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:

  1. Honor Retry-After first. If the header is present, sleep at least that many seconds before retrying.
  2. Never retry faster than 1 second. Tight retry loops make the problem worse.
  3. 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/query can 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/fields vocabulary 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's stream_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