Per-key rate limits as a blast radius control
Per-key request-per-minute limits contain a runaway loop. Give CI, staging, and production different ceilings so one job stalls instead of draining the account.
A Versely API key's rate_limit_rpm is not there to be fair to other customers. It is a fuse on that credential. Default is 60 requests per minute, clamped to 1–1000 at create time. The counter lives in Redis at rl:apikey:<id> on a 60-second window. Cross it and the key gets 429 Rate limit exceeded with retryAfter in seconds, plus X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. A CI job that retries in a tight loop hits its ceiling and stalls. A production poster on a different key keeps publishing. That is the point.
It is not a spend cap. A 5 RPM key that submits a video every twelve seconds still spends credits on every accepted generate. What the fuse stops is a runaway that would otherwise fire as fast as your process can POST. Pair it with a dispatcher that refuses work past a credit ceiling you keep. RPM is the request fuse. The ceiling is the money fuse. Neither is a substitute for the other, and pricing still bills every accepted generation in credits.
Two limiters, two jobs
Per-key RPM is attached to the key row and enforced in API-key auth. Named limiters sit on routes regardless of which credential you presented:
| Limiter | Where | Default | What it contains |
|---|---|---|---|
| Per-key RPM | API key auth | 60/min, set per key, 1–1000 | One credential's request rate |
| Cost-sensitive | /generate |
30/min per IP | Generate POSTs from that IP |
| Agentic chat | /agentic chat |
60/min per user | Chat turns |
| Social post | social post routes | 10/min per user | Publish calls |
| Social sync | social sync routes | 5/min per user | Account sync |
| Auth | credential and key CRUD | 30 per 15 min per IP | Sign-in and key mint/revoke |
A production generate key at rate_limit_rpm: 200 still shares the 30/min IP limiter on /generate with every other caller from that address. Raising the key does not raise the route. CI, staging, and production behind the same NAT share that 30. Split them by IP if generate throughput matters; split them by key so a looping test cannot spend the production key's RPM budget (and so its 429s do not require you to touch production).
Headers on the per-key limiter:
X-RateLimit-Limit: the key's RPMX-RateLimit-Remaining:max(0, rpm - count)in the current windowX-RateLimit-Reset: unix timestamp when the window ends
The 429 body is { success: false, error: "Rate limit exceeded", retryAfter: <seconds> }. Sleep retryAfter, not a guessed 60, and not an exponential that ignores the header. The window is fixed at 60 seconds from the first increment. If Redis is down, this limiter fail-opens and the request proceeds. Do not treat RPM as a financial control.
Named limiters use a slightly different body: error: { code: "429", message: "Too many requests. Please try again later." } plus retryAfter. Parse both shapes.
Ceilings that match the blast radius
Mint one key per job, then set RPM to the worst loop you are willing to absorb from that job. The developer page is where those keys authenticate. The names below are jobs, not environments-as-identity.
| Key | rate_limit_rpm |
Why that number |
|---|---|---|
| CI generate | 5–10 | A unit test that forgot to stub /generate stalls after a handful of calls. Enough to exercise a real request. Not enough to chew a pipeline's credit budget in a minute. |
| Staging worker | 20 | Human-triggered runs and a thin replay of production. Below the 30/min generate IP limiter so staging does not contend with prod on the same NAT. |
| Production generate | 60 (default) or a measured peak | Set this from observed X-RateLimit-Remaining, not from "higher feels safer." Headroom you never use is blast radius you gave away. |
| Production poster | 10 or below | Social post routes already cap at 10/min per user. A poster key does not need 60. |
| Analytics pull | 20 | Reads should not need generate-level RPM. |
| Scratch / laptop | 5, with expires_at |
Personal experiments. Expiry so the fuse is not immortal. |
A full content pipeline that generates, captions, and posts should not run on one key at 60. Split generate and post. Give the poster the low RPM. Give generate only as much as the cadence of a recurring series actually submits. Saved workflows that fire on a schedule are a known rate. CI is not.
The failure mode this is built for is a retry loop that does not read retryAfter. The second failure mode is a fan-out that treats "array of SKUs" as "array of unbounded POSTs." Per-key RPM will not make that loop correct. It will make it loud and cheap to notice: remaining hits zero, 429s show up in the worker log, credits stop accelerating. If generate and post share a key, the 429s also stall publishing, which is how a render bug becomes an outage on the channel. Separate keys keep the stall inside the job that broke.
What to do with the 429
Honor retryAfter. Honor X-RateLimit-Reset if you are scheduling the next window rather than sleeping once. Do not retry POST /generate as a way to wait: there is no idempotency key, so a retry that actually lands is a second job and a second charge. Status polling is the cheap way to wait on work you already submitted. Generating again is how a 429-recovery path becomes a spend event.
If CI is the key that 429s, let the build fail. Raising that key's RPM so the looping test "passes" is how you move the blast into staging. If production 429s at the default 60, measure whether the worker is polling too hot, submitting too many one-shot generates, or sharing the 30/min IP limiter with another process. Those are three different fixes. Only the first two belong on rate_limit_rpm.
Track credits per job the same way you track RPM, or the fuse you did not trip still drains the wallet through successful calls. Tracking credits per client, per deliverable is the ledger version of the same blast-radius idea. RPM contains the request. The ledger tells you whether the requests that got through were ones you meant to pay for. Credits, as billed, are the unit both fuses are protecting.
Create the ceiling when you mint the key. There is no PATCH. Changing RPM is a rotation: new key with the new rate_limit_rpm, swap the job, revoke the old row.
POST /api/v1/auth/api-keys
Authorization: Bearer <session JWT>
Content-Type: application/json
{
"name": "ci-generate",
"scopes": ["generate", "read"],
"rate_limit_rpm": 8
}
A value below 1 is stored as 1. A value above 1000 is stored as 1000. Read the row back. Do not assume the number you sent is the number you got.
FAQ
Does a 429 mean I was not charged?
For the rejected request, yes: auth failed the rate check before the handler ran. Any generate that already returned a request_id is in flight and bills on that path. Do not recover from a 429 by repeating the POST unless you intend to start a second job.
Can I set RPM to 1000 and forget about it?
You can. You have then set the fuse to the top of the clamp and are relying on the 30/min generate IP limiter and the social/agentic named limiters. A looping CI job that uses a 1000 RPM key and does not hit /generate (for example, it hammers /user or /ai-models) will run until something else stops it. The per-key number exists so that job can be 8.
Why did production 429 at 30 when the key is 60?
/generate has a 30/min per-IP limiter in addition to per-key RPM. Two workers on one IP share that 30. The per-key headers will still show 60 as the limit. Read the body: named limiters return error.code: "429" with a message about too many requests; the per-key body is the string Rate limit exceeded. That difference tells you which fuse tripped.
If Redis is down, am I unprotected?
The per-key limiter fail-opens on Redis errors: the request is allowed and the RPM headers may be missing. Named limiters fall back to an in-memory counter. Design the dispatcher as if RPM is best-effort containment, and keep a credit ceiling in your own process for the case where the fuse does not trip.