rate_limited
HTTP 429 retryable: true REST and MCP
A request-rate limit refused this call, and the response carries Retry-After so you know how long to wait.
Why it happens
There are two different kinds of limit behind this code and they behave differently. The general one is a request-rate throttle with three buckets: an edge bucket keyed on a hash of the client IP, a workspace bucket, and a principal bucket keyed by api key, connection or identity. Only verified principal fields feed the last two, never a workspace header, so a caller cannot spread its load across buckets by claiming a different workspace. The other kind is a per-operation cooldown on things that are expensive or that send mail of their own, and those have fixed, much longer windows.
Every condition below was read in the implementation at the file and line given. They are the branches that raise this code, not a summary of what the name suggests.
- The workspace bucket or the principal bucket rejected the request. The principal bucket is keyed on the verified api key id, connection id or identity id.
packages/domain/src/request-guards.ts:113 - The edge bucket, keyed on an HMAC of the client IP, rejected the request before any principal was resolved.
packages/domain/src/request-guards.ts:39 - A third export was requested within an hour by the same member. The limit is two per hour per member.
packages/domain/src/exports.ts:206 - A webhook test event was sent within 60 seconds of the previous one on the same endpoint.
packages/domain/src/services.ts:3302 - Inbox creation was deferred before the provider was contacted and the local reservation was confirmed released. No address or capacity was retained.
packages/domain/src/local-inbox-reservation.ts:55
How to fix it
- Honour Retry-After. The header is set to 60 on every rate_limited response from both the REST API and the MCP endpoint.
- Back off exponentially with jitter rather than retrying on a fixed interval. This is the one family of errors where retrying is correct, which makes a synchronised retry storm the realistic failure.
- Spread work across time rather than across credentials. Buckets are keyed on the verified principal and on the workspace, so a second key in the same workspace shares the workspace bucket.
- For exports, batch the request. Two per hour per member is the cap, and a wider single export is cheaper than several narrow ones.
Why it works this way
The edge counter is treated as permissive and never as billing authority. It is local to a Cloudflare location and therefore cannot see global traffic, so it is used to shed obvious load early and the real accounting happens on the principal and workspace buckets behind it.
Messages you will see
The message field is written for a human reading a log and is not a contract
value. Branch on code instead. These are the exact strings this code is sent
with today, listed so that a search for one of them lands here.
Where it appears
These are the documented surfaces that raise it. The REST base is https://api.emailforagents.ai/v1, and the remote MCP endpoint is https://mcp.emailforagents.ai/mcp.
Every /v1 routePOST /projects/{project_id}/exportsPOST /projects/{project_id}/webhooks/{webhook_id}/testPOST https://mcp.emailforagents.ai/mcp
The response
Read request_id off the body or the x-request-id header before
doing anything else. It is the only handle on one specific attempt, and for a 503 it is
the only handle at all, because the underlying detail stays server-side.
HTTP/1.1 429
content-type: application/json
x-request-id: req_...
retry-after: 60
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Retry shortly.",
"request_id": "req_...",
"retryable": true
}
}
Verified against packages/domain/src/request-guards.ts:113. The status comes from ERROR_STATUS in packages/contracts/src/errors.ts, which is the one
place a code is mapped to a status.