Gatekeeper throttles abusive traffic with fixed-window rate limits. The limit fronts the rest of the decision pipeline - it runs before identity resolution, so brute force on a credential is throttled before any expensive backend call. When a window is exceeded the request gets a 429 with a Retry-After header.
The tiers#
There are four independent tiers, each a (limit, period) fixed window. The period is in seconds and is constrained to 10 or 60 (a platform constraint of the edge limiter). These are the defaults; each is overridable per deploy.
| Tier | Default limit | Window | Guards |
|---|---|---|---|
credential | 5 | 60s | The user-credential path: signup, login, refresh - the brute-force surface. |
key | 60 | 60s | API-key validate / token operations. |
platformCredential | 60 | 60s | Platform service-account and bootstrap credential operations. |
webhook | 600 | 60s | The inbound payment-webhook endpoint. |
The tightest budget is credential on purpose: a handful of attempts per minute per source is plenty for a real user and starves a password-guessing loop.
What a throttled response looks like#
When a tier is exceeded:
- HTTP status is
429. - The body is the standard error envelope with
code: "rate_limited". - A
Retry-Afterheader carries the window length in seconds, telling the caller when the window resets.
// 429 response body
{ "ok": false, "error": "too many requests", "code": "rate_limited" }
// header: Retry-After: 60If the limiter itself or the forge-proof client IP is unavailable, the request fails closed with 503 (rate limiting unavailable) rather than being let through.
Edge-atomic vs in-memory#
How the window is counted depends on the runtime:
| Runtime | Mechanism | Property |
|---|---|---|
| Cloudflare Workers | Native [[ratelimits]] edge binding | The increment is atomic per key, so a concurrent burst cannot overshoot the limit. |
| Bun | A cache-backed fixed-window limiter behind the same minimal contract | A read-then-write counter; under heavy concurrency a burst can slightly overshoot before the window settles. |
Both satisfy the same minimal limiter contract (limit({ key }) => { success }), which is why the identical engine runs on both. The atomic edge path is the production posture; the in-memory path is a faithful local-development equivalent with the overshoot caveat above.
How the SDK surfaces a 429#
A 429 becomes a GatekeeperError with code === 'rate_limited' and a populated retryAfter (parsed from the Retry-After header). Back off for that many seconds before retrying.
import { GatekeeperError } from '@orkait/sdk';
async function withBackoff<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (err) {
if (err instanceof GatekeeperError && err.code === 'rate_limited' && i < attempts) {
const waitMs = (err.retryAfter ?? 1) * 1000;
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
throw err;
}
}
}Related#
- Responses and errors - the
429code and the full status table - Architecture - where the rate-limit step sits in the pipeline
- Idempotency - making retries after a throttle safe