API Rate Limiting skill for Codex
Fair limits that hold under concurrency. Codex matches the limiter to the goal: brute-force protection, tenant fairness, plan quotas or expensive endpoints. It keys limits on authenticated identity, makes each decision atomic with tested Redis Lua scripts for token bucket, GCRA, sliding window and concurrency, returns 429 with Retry-After, chooses fail-open or fail-closed on purpose, and rolls out in shadow mode first.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- api-rate-limiting Installs to ~/.agents/skills/api-rate-limiting/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install api-rate-limiting The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project api-rate-limiting Installs to .agents/skills/api-rate-limiting/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/api-rate-limiting.zipunzip -o api-rate-limiting.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) api-rate-limiting PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $api-rate-limiting, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs and implements API rate limits and quotas - token bucket, GCRA, sliding window and concurrency limiters, per-key and per-tenant policies, weighted costs, Redis-backed atomic counters, 429 responses with Retry-After and RateLimit headers, and gateway-level limits in NGINX or framework middleware. Use when adding rate limiting to an API, protecting login or expensive endpoints, enforcing plan quotas per tenant, fixing limits that leak under concurrency, or when clients ask how to back off correctly.
Use it when
- Adding rate limits or quotas to an API
- Protecting login, OTP or expensive endpoints from abuse
- An existing limiter over-admits under concurrency or multiple replicas
- Defining 429 responses and backoff guidance for clients
Use something else when
- Blocking malicious traffic patterns at the edge with rules: use waf-rules
- Retrying or smoothing load to a fragile downstream: use resilience-patterns
How to check the result
A fake-clock unit test proves exact burst and Retry-After values, and firing twice the limit in parallel admits exactly the limit across two app instances.
The mistake it prevents
Rate limiting on req.ip behind a load balancer without trusted-proxy configuration, so every client shares one bucket and the whole API locks up at once.
A rate limiter is right when it protects the system and fairly shares capacity between tenants, decides atomically under concurrency, tells clients exactly when to retry, and fails in a deliberate direction when its own store is down.
Workflow
- [ ] 1. Identify what you are protecting and from whom
- [ ] 2. Choose the key, algorithm and limits
- [ ] 3. Choose the enforcement layer
- [ ] 4. Implement atomically
- [ ] 5. Return 429 with retry information
- [ ] 6. Observe, tune and document
1. What and whom
Read AGENTS.md, existing middleware, gateway config (NGINX, Envoy, API gateway,
Cloudflare) and any plan/billing definitions. Then classify the goal, because each needs
a different limiter:
| Goal | Limiter |
|---|---|
| Stop abuse and brute force (login, OTP, password reset) | Strict per-identifier + per-IP limits, long windows, no burst |
| Keep one tenant from starving others | Per-tenant token bucket sized from plan |
| Enforce a commercial quota (10k calls/month) | Quota counter with calendar windows, separate from burst limit |
| Protect an expensive endpoint (exports, AI calls, search) | Concurrency limit and/or weighted cost |
| Shield a fragile downstream | Client-side limiter or queue in front of that dependency |
Most APIs need two layers: a coarse per-IP limit at the edge and a per-tenant or per-key limit in the application.
2. Key, algorithm, numbers
Key: the most specific authenticated identity available. API key or tenant ID for
authenticated traffic; IP (or IPv6 /64 prefix) only for anonymous traffic. Include the
route group when limits differ by endpoint: rl:{tenant_42}:search.
| Algorithm | Behavior | Use when |
|---|---|---|
| Token bucket / GCRA | Steady rate plus bounded burst | Default for API traffic |
| Sliding window counter | Smooth “N per window” with constant memory | Hourly/daily quotas |
| Fixed window | Simple counter per window, allows 2x bursts at boundaries | Monthly quotas, where boundary bursts do not matter |
| Sliding log (sorted set of timestamps) | Exact, memory grows with limit | Low limits needing exactness (5 logins per 15 minutes) |
| Concurrency limit | Caps in-flight requests | Long or expensive requests |
Sizing: start from measured traffic. Take p99 per-key request rate over a week, set the sustained rate above it with headroom (2-3x), burst around 2-5 seconds of the sustained rate, then verify that the sum of plausible concurrent tenants fits system capacity. Weighted costs (a bulk endpoint costs 10 tokens) are fairer than separate limits per endpoint.
3. Enforcement layer
| Layer | Strength | Limit |
|---|---|---|
| CDN/WAF/edge | Absorbs floods before they reach you | Coarse identity (IP), little business context |
Reverse proxy (NGINX limit_req, Envoy) | Cheap, fast | Per-instance unless using a global rate-limit service |
| Application middleware with shared store (Redis) | Knows tenant, plan, cost | Adds a store round trip per request |
In-process (golang.org/x/time/rate, memory stores) | No network | Per instance only; limits multiply by replica count |
NGINX edge limit:
limit_req_zone $binary_remote_addr zone=api_ip:10m rate=20r/s;
server {
location /api/ {
limit_req zone=api_ip burst=40 nodelay;
limit_req_status 429;
}
}
Framework options, prefer them before writing your own: express-rate-limit with a
Redis store (standardHeaders option controls header style), @fastify/rate-limit,
Rails rate_limit to:, within: in controllers (7.2+) or Rack::Attack, Django
django-ratelimit, ASP.NET Core AddRateLimiter (fixed, sliding, token bucket,
concurrency policies), Spring with Bucket4j. Installing a new package may need network
and approval in the sandbox.
4. Implement atomically
- The decision (read, compute, write) must be one atomic operation. Application code
that does
GETthenSETlets concurrent requests all pass. - With Redis, use a single Lua script per decision, one key per decision (cluster-safe;
use hash tags like
{tenant_42}if multiple keys must co-locate), and server time fromTIME. - For ready-to-use token bucket, GCRA, sliding window and concurrency scripts, read references/redis-lua.md when no maintained library fits.
- Decide failure mode explicitly when the store is unreachable: fail open (allow, log, alert) for general API traffic; fail closed for login/OTP endpoints where abuse is the bigger risk. Put a short timeout (tens of milliseconds) on the limiter call.
5. Respond correctly
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 7
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 7
{"type":"https://api.example.com/problems/rate-limited","title":"Rate limit exceeded",
"status":429,"detail":"Search is limited to 100 requests per minute for your plan."}
Retry-After(seconds or HTTP-date) is the standard signal; always send it with 429.- The IETF
RateLimit/RateLimit-Policyheader fields are still a draft and their syntax changed between revisions. Either pin to one draft revision and document it, or keep the widely understoodX-RateLimit-*trio. Document whetherResetis seconds-until or an epoch timestamp; clients guess wrong otherwise. - Send limit headers on successful responses too so clients can pace themselves.
- Quota exhaustion (monthly plan) is still 429, with a body that says it is a quota and when it resets; 402/403 confuse SDK retry logic.
- Overload protection unrelated to a client’s own usage is 503 with
Retry-After.
6. Observe and document
- Emit a metric for every decision:
ratelimit_decisions_total{policy, result}. Alert when a single tenant’s denials spike or when overall denials exceed a few percent. - Log denied requests with key and policy (not the full request) at sampled rates.
- Publish limits per plan and the backoff contract (honor
Retry-After, exponential backoff with jitter otherwise). - Provide a shadow mode (log would-deny without denying) for rollout; run it for a week on production traffic before enforcing new limits.
Gotchas
- Behind a load balancer,
req.ipis the proxy unless trusted-proxy settings are configured; every client then shares one bucket. Trust only your own proxy hops; never readX-Forwarded-Forblindly, attackers set it. - In-memory limiters multiply by replica count and reset on deploy.
- Per-IP limits punish users behind carrier-grade NAT and corporate proxies; prefer authenticated keys wherever possible.
- Counting rejected requests against the bucket lets a misbehaving client lock itself out forever; usually only count admitted requests (except for brute-force limiters).
- Clock skew between app servers breaks limiters that use client-side timestamps; use the store’s clock.
- Retries from your own SDK without jitter synchronise into waves exactly at
Reset.
Verification
- Unit test the algorithm with a fake clock: exact burst size, denial after burst,
refill after waiting, correct
Retry-After. - Concurrency test: fire 2x the limit in parallel (for example
xargs -P 50 curl) and confirm admitted count equals the limit, not more. - Integration test against real Redis (testcontainers or a local instance) that two app instances share one budget.
- Stop Redis in a test environment and confirm the chosen fail-open/closed behavior.
Output template
## Rate limit policy
| Policy | Key | Algorithm | Limit | Burst | Layer | Store-down behavior |
|---|---|---|---|---|---|---|
| api-default | api key | GCRA | 100/min | 20 | app (Redis) | fail open |
| login | email + IP | sliding log | 5/15 min | 0 | app (Redis) | fail closed |
- Headers sent: <list>
- Rollout: shadow mode from <date>, enforce on <date>
- Metrics and alerts: <names> What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "API Rate Limiting"
short_description: "Fair limits that hold under concurrency"
default_prompt: "$api-rate-limiting Add per-tenant rate limiting to our API using Redis" references/redis-lua.md
# Atomic Redis limiters (Lua)
Read this when the project has no rate-limit library that fits, or when an existing
limiter shows race conditions (read-then-write from application code). Each script uses
one key so it works in Redis Cluster, reads the clock with `TIME` on the server (no
client clock skew), and returns strings for fractional values because Redis truncates
Lua numbers to integers on the way out.
Load once with `SCRIPT LOAD` and call with `EVALSHA`, or let your client library do it
(`ioredis` `defineCommand`, redis-py `register_script`, go-redis `NewScript`).
## Token bucket
Smooth refill, allows bursts up to `capacity`, supports weighted `cost`.
```lua
-- KEYS[1] = bucket key, e.g. "rl:tb:{tenant_42}:search"
-- ARGV[1] = capacity (max tokens), ARGV[2] = refill rate (tokens/second), ARGV[3] = cost
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1000000
local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1]) or capacity
local ts = tonumber(state[2]) or now
tokens = math.min(capacity, tokens + math.max(0, now - ts) * rate)
local allowed = 0
local retry_after = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
else
retry_after = (cost - tokens) / rate
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / rate * 1000) + 1000)
return { allowed, tostring(math.floor(tokens)), tostring(retry_after) }
```
Returns `{allowed (0|1), remaining, retry_after_seconds}`. Round `retry_after` up to a
whole second for the `Retry-After` header.
## GCRA (generic cell rate algorithm)
Same behavior as a token bucket but stores a single timestamp (the theoretical arrival
time), so it is cheaper and has no refill drift. `limit` requests per `period` seconds,
with up to `burst` requests allowed back to back.
```lua
-- KEYS[1] = key
-- ARGV[1] = limit, ARGV[2] = period (seconds), ARGV[3] = burst, ARGV[4] = cost
local limit = tonumber(ARGV[1])
local period = tonumber(ARGV[2])
local burst = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local interval = period / limit -- seconds per request at steady rate
local burst_window = interval * burst
local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1000000
local tat = tonumber(redis.call('GET', KEYS[1])) or now
if tat < now then tat = now end
local new_tat = tat + interval * cost
local allow_at = new_tat - burst_window
if now < allow_at then
return { 0, '0', tostring(allow_at - now) }
end
redis.call('SET', KEYS[1], tostring(new_tat), 'PX', math.ceil((new_tat - now) * 1000) + 1000)
local remaining = math.floor((now - allow_at) / interval)
return { 1, tostring(remaining), '0' }
```
Sanity check: with `burst = 5`, five calls at the same instant are allowed, the sixth
is denied with `retry_after = interval`.
## Sliding window counter
Approximates a true sliding window with two counters; error is small and memory is
constant. Good for "1000 requests per hour" quotas.
```lua
-- KEYS[1] = key; ARGV[1] = limit, ARGV[2] = window (seconds), ARGV[3] = cost
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1000000
local cur_start = math.floor(now / window) * window
local s = redis.call('HMGET', KEYS[1], 'w', 'c', 'p')
local w = tonumber(s[1])
local cur = tonumber(s[2]) or 0
local prev = tonumber(s[3]) or 0
if w == nil or w < cur_start - window then
prev, cur = 0, 0
elseif w < cur_start then
prev, cur = cur, 0
end
local weight = 1 - (now - cur_start) / window
local estimate = prev * weight + cur
if estimate + cost > limit then
local retry = window - (now - cur_start)
if prev > 0 then
-- time until the previous window's weighted share drops enough
local need = estimate + cost - limit
retry = math.min(retry, need / prev * window)
end
redis.call('HSET', KEYS[1], 'w', cur_start, 'c', cur, 'p', prev)
redis.call('EXPIRE', KEYS[1], window * 2)
return { 0, '0', tostring(retry) }
end
cur = cur + cost
redis.call('HSET', KEYS[1], 'w', cur_start, 'c', cur, 'p', prev)
redis.call('EXPIRE', KEYS[1], window * 2)
return { 1, tostring(math.floor(limit - estimate - cost)), '0' }
```
## Concurrency limiter (in-flight requests)
For expensive endpoints (exports, report generation), limit simultaneous requests
instead of rate. Use a sorted set of request IDs scored by start time; remove entries
older than the maximum request duration so crashed workers do not leak slots.
```lua
-- KEYS[1] = key; ARGV[1] = max concurrent, ARGV[2] = request id, ARGV[3] = max duration (s)
local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1000000
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - tonumber(ARGV[3]))
if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[1]) then
return 0
end
redis.call('ZADD', KEYS[1], now, ARGV[2])
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]) * 2)
return 1
```
Release with `ZREM key request_id` in a `finally` block.
## Calling from Node (ioredis)
```ts
import Redis from 'ioredis';
import { readFileSync } from 'node:fs';
const redis = new Redis(process.env.REDIS_URL!);
redis.defineCommand('tokenBucket', { numberOfKeys: 1, lua: readFileSync('token_bucket.lua', 'utf8') });
const [allowed, remaining, retryAfter] = await (redis as any).tokenBucket(
`rl:tb:{${tenantId}}:api`, 100, 10, 1,
);
```
## Testing the scripts
- Unit-test the Lua logic without Redis by stubbing `redis.call` in a plain Lua
interpreter and advancing a fake clock, or run against a disposable Redis
(testcontainers) in integration tests.
- Assert: burst size is exact, the request after the burst is denied with the expected
`retry_after`, and tokens refill after waiting.
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.