Rate limits

Inbox applies a coarse edge limit of 6000 requests per 60-second fixed window, per credential. It is deliberately generous: it exists to stop a runaway loop or a credential-stuffing sweep, not to shape normal traffic.

private static readonly LIMIT = 6000;
private static readonly WINDOW_SECONDS = 60;

It runs before authentication

RateLimitGuard is the first guard in the pipeline, ahead of AuthGuard. Two consequences:

Bucket key derivation

Resolved in order, first match wins:

Order Source Bucket key
1 x-api-key header tok:<sha256(key)[0:32]>
2 Authorization: Bearer <token> tok:<sha256(token)[0:32]>
3 Request IP ip:<address>
4 Nothing above anon

A service key may arrive on either header, so both are bucketed identically — you cannot double your allowance by alternating headers.

The hash is not decoration. The bucket key becomes a Redis key and shows up in metrics and logs, and credentials are opaque to inbox (Accounts owns them). Hashing means the secret never reaches either place. Truncating to 32 hex characters keeps the key short while leaving ample collision headroom.

Note that ip: and anon are shared buckets. Unauthenticated traffic from behind one NAT shares one allowance, and anything the guard cannot key at all shares a single global bucket.

When you exceed it

{
  "error": {
    "code": "rate_limit_exceeded",
    "message": "Too many requests.",
    "requestId": "01J8Z…",
    "rateLimit": { "limit": 6000, "remaining": 0, "resetAt": 1756654380 }
  }
}

Status is 429. AllExceptionsFilter also sets a retry-after response header, in seconds, computed as max(0, resetAt - now). resetAt itself is unix seconds.

This is a fixed window, not a sliding one: the counter resets wholesale at resetAt. Traffic clustered around a window boundary can therefore land up to 12000 requests in a rolling minute without ever being refused.

Redis-backed or per-process

When INBOX_REDIS_URL is set, counting goes through RedisRateLimiter, which does INCR plus EXPIRE-on-first plus a TTL read in one Lua script — atomic, so there is no stuck-key race and no partial state. Every instance shares one counter.

When it is not set, the service falls back to InMemoryRateLimiter: a plain Map in the process.

The effective limit then multiplies by your instance count. Four instances behind a load balancer give a single credential up to 24000 requests per minute, and which instance a given request lands on decides which counter it touches. In-memory buckets also vanish on restart. That is fine for local development and wrong for production — if you run more than one instance, set INBOX_REDIS_URL.

Handling it correctly

Respect retry-after and cap it. An upstream or a misconfigured proxy answering Retry-After: 86400 should not park your request for a day holding a socket.

async function call(req: Request): Promise<Response> {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(req);
    if (res.status !== 429 && res.status < 500) return res;
    if (attempt >= 3) return res;

    const retryAfter = Number(res.headers.get("retry-after"));
    const waitSeconds =
      res.status === 429 && retryAfter > 0
        ? Math.min(retryAfter, 60)          // cap
        : Math.min(2 ** attempt, 8);        // backoff for 5xx
    await res.body?.cancel().catch(() => undefined);  // don't pin keep-alive sockets
    await new Promise((r) => setTimeout(r, waitSeconds * 1000));
  }
}

That is what @~inbox/sdk does. It retries 429 and 5xx up to maxRetries (default 3), caps Retry-After at 60 seconds, drains the response body before sleeping, and — the part that makes the retry safe — reuses the same Idempotency-Key across attempts, so a retried write replays instead of duplicating. See Idempotency.

A different limiter: per-connection provider budgets

Do not confuse the edge limit with ConnectionRateLimiterService, which protects the provider, not inbox. It buckets on conn-cap:<connectionId>:<capability> and is consumed at the point inbox is about to call out to a network:

Where Capability Budget
Thread reply the thread's messaging capability 200 / hour
private_reply on a comment comments 200 / hour
Post dispatch publish 1000 / hour
Contact profile enrichment profile-enrich its own small budget

It throws the same rate_limit_exceeded code with the same rateLimit payload, so a 429 from inbox may mean either "you are calling us too fast" or "this connection has spent its hourly budget with its provider". They are told apart by the numbers: limit: 6000 is the edge limit; anything else is a per-connection budget.

Profile enrichment treats its own 429 as "skip this time", never as a failure — enrichment is opportunistic and must not fail an ingest.