# Rate limits and quotas

A public endpoint meets runaway agents and abuse, so the server limits what any one caller can
consume. This page states the limits, what happens when you hit one, and where the numbers are
approximate rather than exact.

Limits apply to the **remote HTTP transport only**. Local `stdio` is a single user on their own
machine and is deliberately unlimited; adding friction there would only slow down the person running
the server.

## The four layers

| Layer                  | Default                        | Keyed by            | What it protects                             |
| ---------------------- | ------------------------------ | ------------------- | -------------------------------------------- |
| Pre-auth               | 1 request/second, burst 5      | Client IP           | Scanning and floods from any unproven caller |
| Per-tenant rate        | 5 requests/second, burst 20    | API key fingerprint | One tenant starving another                  |
| Per-tenant daily quota | 5,000 calls per UTC day        | API key fingerprint | Runaway agents and cost                      |
| Global                 | 100 requests/second, burst 200 | The whole instance  | The upstream TiDB Cloud API                  |

They are checked in that order, and the order is the point.

**A credential earns a tenant bucket by being accepted, never by being presented.** Until a
credential has authenticated successfully at least once, its requests come out of the per-IP
pre-auth budget, exactly as requests with no credential at all do. This is what makes the pre-auth
layer worth having: keying on whatever arrives in the header would let a scanner rotate a fake
credential per request and receive a fresh full bucket every time, which is a limiter that counts to
one forever. The cost is that a genuinely new tenant spends its first request from the IP budget,
which is the price of proving itself.

An unproven request is therefore refused before anything validates a credential, builds a tenant
context, or calls upstream, so a scanner costs a map lookup rather than a round trip.

Tenant is checked before global, so a tenant already over its own limit cannot spend shared capacity
on requests that are about to be refused anyway. The reverse order let one noisy caller exhaust the
instance ceiling and have every other tenant told "global", which would defeat the cross-tenant
fairness this layer exists to provide.

## Where the client address comes from

The pre-auth bucket is keyed by client address, and after the rule above that bucket is the only
door an unproven caller passes through. So the address has to come from somewhere the caller cannot
write.

`X-Forwarded-For[0]` is not such a place. A proxy that appends leaves the caller's own claim in
front of the address it observed, so reading the first entry lets a caller mint a fresh budget per
request by rotating it, and drain a named victim's budget by asserting their address. The second is
the worse one: after the known-good rule, holding a gateway's bucket empty prevents its tenants from
ever proving themselves.

The server therefore reads the address only from a declared source:

- `TIDB_MCP_CLIENT_IP_HEADER`, for a header the proxy SETS rather than appends (`fly-client-ip`,
  `true-client-ip`, `cf-connecting-ip`).
- `TIDB_MCP_TRUSTED_PROXY_DEPTH`, for `X-Forwarded-For`, counting from the RIGHT because each proxy
  appends what it saw. Set this only where the hop count is fixed.

With neither set, the connection address is used and the header is ignored completely. Behind a load
balancer that means every unproven caller shares one bucket, which is unhelpful but true; the server
logs a warning at startup so the state is visible. Guessing at the header would be confidently
wrong instead, which is what this rule exists to prevent.

`TIDB_MCP_GATEWAY_IPS` lists addresses known to front many tenants, such as a partner platform. They
get a much larger proving budget, because otherwise every tenant behind one address has to reprove
through a single 1 r/s door after each deploy. It is a bigger budget and never an exemption: past it
the address is still refused, so reaching us from a listed address does not let anyone mint
unlimited buckets with rotated credentials.

Every layer is env-configurable: `TIDB_MCP_PREAUTH_RPS`, `TIDB_MCP_PREAUTH_BURST`,
`TIDB_MCP_TENANT_RPS`, `TIDB_MCP_TENANT_BURST`, `TIDB_MCP_DAILY_QUOTA`, `TIDB_MCP_GLOBAL_RPS`,
`TIDB_MCP_GLOBAL_BURST`.

## What you get when you hit one

HTTP 429 with a `Retry-After` header in seconds, plus a JSON-RPC error whose message is written for
an agent to act on rather than for a human to read afterwards:

```json
{
  "jsonrpc": "2.0",
  "error": {
    "code": -32002,
    "message": "Rate limited: this API key exceeded its per-second request limit. Retry in 3s. ...",
    "data": {
      "limit": "tenant",
      "code": "TENANT_RATE_LIMITED",
      "retryAfterSeconds": 3
    }
  },
  "id": null
}
```

The distinction that matters most is between a rate limit and the daily quota. A rate limit is a
short pause and the same call works after the wait. Quota exhaustion is not: no amount of backoff
inside the same UTC day will clear it, so the message says to stop retrying that key and report the
exhausted quota instead. An agent that treats the two the same will either give up too early or loop
until midnight.

`Retry-After` for a quota refusal is the number of seconds to the next UTC midnight.

Limits apply to preflight and confirm exactly as they apply to anything else. An exemption lane for
confirms would be a bypass, and one aimed at destructive operations. A rate-limited confirm can
always be retried after `Retry-After`.

## Where the numbers are approximate

**Rate limits are per server instance, and the deployment runs more than one.** Bucket state lives
in memory, so with two tasks behind a load balancer the observed rate is roughly double the number
in the table. This is deliberate: putting a per-request limit check on a network hop would add that
hop's latency to every request and its outage to our availability. The trade is a limit that is
approximate rather than a hot path that is fragile.

**The daily quota is genuinely shared, and converges within one flush interval.** Counters are held
in memory; every 30 seconds each instance pushes the calls it has counted SINCE ITS LAST FLUSH and
adopts the resulting shared total. Deltas rather than absolute counts, because writing absolutes
made the stored figure the largest count any single instance had reached rather than the sum, so N
instances each served the full limit. An instance that has served no traffic still reads on each
flush, so it cannot sit on a stale zero and hand out a fresh allowance later. A crash can lose up to
one flush interval of counts.

**If the control database is unreachable, quotas keep working in memory and the server keeps
serving.** Quotas are cost control, not a safety gate, so failing open is the right direction:
refusing a customer's traffic because of a bookkeeping outage would be the worse failure. The confirm
nonce ledger in the same database does the opposite and fails closed, because it guards whether a
destructive operation may proceed. Same store, opposite failure directions, on purpose.

`tidb_capabilities` reports the limits in force, including a flag for whether quota persistence is
currently degraded, so an agent can read the contract rather than infer it from being refused.

**A credential leaves the set in three ways.** It is forgotten when the upstream REJECTS it, so a
revoked key stops carrying a tenant budget and falls back to the anonymous rate, which is the case
this layer most exists for. It expires 24 hours after it was last used, so proof never becomes
permanent on a deployment too small to trigger the LRU ceiling. And it is dropped under that ceiling
when there are more credentials than the cap.

Rejection only, never any other failure. Validation also fails on a 5xx or a timeout, and forgetting
then would let one TiDB Cloud incident empty every tenant at once and push the whole deployment back
through the anonymous door, turning someone else's outage into a second one of our own.

**Revocation is noticed when the credential is revalidated, which is not the same as the next
request.** The server validates a key once and caches the resulting context for 30 minutes of IDLE
time, refreshing that timer on every use. A credential in continuous use is therefore never
revalidated, so a key revoked upstream keeps both its cached context and its tenant budget until
its traffic pauses long enough for the context to expire. A credential that calls intermittently
picks up the revocation on its next call after that gap.

This is a property of context caching rather than of rate limiting, and the cache is what keeps an
upstream round trip off every request. If revocation latency ever matters more than that, the lever
is the context lifetime rather than anything in this document: either shorten the idle window, or
add a maximum age so a long-lived credential revalidates on a schedule regardless of how busy it is.
Both cost an upstream call per tenant per interval, which is why neither is the default.

**The known-good set is per instance and held in memory, so a deploy makes every tenant prove itself
again.** With the address fixed this costs one request per tenant from its own address, and the
gateway allow-list above absorbs the case where many tenants share one. Persisting the set next to
the quota counters would make proving a once-per-tenant event rather than a once-per-deploy one, and
that is a tracked follow-up rather than something this build does.

**An OAuth token that rotates is a new credential and pays the proving cost again.** The obvious fix,
keying on a claim such as `sub` instead of the raw token, does not work as stated: the middleware
runs before validation, so it would have to trust an unverified claim, and an attacker-supplied
`sub` reopens exactly the bypass the known-good rule closes. The mitigations that do hold are the
gateway allow-list, for the case where many tenants refresh independently behind one address, and
the fact that a single tenant pays one pre-auth token per refresh, which is cheap.

## Division of labor with the WAF

In the hosted deployment, AWS WAF sits in front and handles IP-level floods, known bad inputs, and
reputation blocking before traffic reaches this server. The application layer described here handles
what WAF structurally cannot: telling tenants apart.

The concrete reason is gateways. A partner platform such as Workato fronts many customers behind one
IP address, so an IP-based limit there would either punish all of them for one caller's behavior or
none of them for any. Per-tenant fairness has to happen where the credential is visible, which is
here. Both layers exist on purpose and neither replaces the other.

## The `/v1` REST surface

`/v1` uses the same limiter and therefore the same token bucket and the same global ceiling, since
both surfaces load the same upstream.

Its identity is computed in ONE function used by both the limit check and the
post-authentication mark. Writing those separately is how they drifted once already: the check read
the raw header while the mark used the resolver, so the key that authenticated was never the key
that was consulted and the surface sat on the anonymous budget no matter how often it authenticated.
The suite now drives `/v1` through the limiter end to end, which is the only way that class of bug
shows up.

Its identity is weaker than `/mcp`'s, and that is a property of its authentication rather than of
this code. Every
`/v1` caller presents the SAME shared bearer token, so the token alone would put an entire workspace
in one bucket and let one busy recipe deny the connector to everyone else. The client IP alone is no
better once a gateway fronts many users from a few addresses. The key is therefore the token
combined with the client IP, which is the finest identity that auth model can express. Real
per-tenant fairness on `/v1` needs per-tenant credentials, which is a change to the transport.

## Observability

Refusals are counted exactly per layer in the metrics snapshot at `/metrics`. The matching
`rate_limited` telemetry events are sampled once a flood passes the threshold, because under attack
they arrive at the attacker's rate and recording each one would push real events out of a bounded
queue. Each sampled event carries `representsCount` so totals stay reconstructable, and the exact
totals live in metrics regardless. This is the only event kind allowed to be sampled.

## Statement for customer-facing docs

> Each API key may make up to 5 requests per second, with short bursts of up to 20, and up to 5,000
> calls per UTC day. Exceeding a limit returns HTTP 429 with a `Retry-After` header. Per-second
> limits clear after a short wait; the daily quota resets at UTC midnight. Requests without a valid
> API key are limited far more strictly. Rate limits are enforced per server instance, so the
> effective rate may be somewhat higher than the stated figure. Contact us if you need a higher
> quota.
