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.

  • Backend & APIs
  • $api-rate-limiting
  • 173 lines
  • 3 files
  • ~2,000 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- api-rate-limiting

Installs to ~/.agents/skills/api-rate-limiting/ for every project on this machine.

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.

~/.agents/skills/api-rate-limiting/SKILL.md

--- name: api-rate-limiting description: Designs and implements API rate limits and quotas - token bucket, GCRA, sliding window and concurrency limiters, per-key and per-tenant policies,… license: MIT ---

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:

GoalLimiter
Stop abuse and brute force (login, OTP, password reset)Strict per-identifier + per-IP limits, long windows, no burst
Keep one tenant from starving othersPer-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 downstreamClient-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.

AlgorithmBehaviorUse when
Token bucket / GCRASteady rate plus bounded burstDefault for API traffic
Sliding window counterSmooth “N per window” with constant memoryHourly/daily quotas
Fixed windowSimple counter per window, allows 2x bursts at boundariesMonthly quotas, where boundary bursts do not matter
Sliding log (sorted set of timestamps)Exact, memory grows with limitLow limits needing exactness (5 logins per 15 minutes)
Concurrency limitCaps in-flight requestsLong 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

LayerStrengthLimit
CDN/WAF/edgeAbsorbs floods before they reach youCoarse identity (IP), little business context
Reverse proxy (NGINX limit_req, Envoy)Cheap, fastPer-instance unless using a global rate-limit service
Application middleware with shared store (Redis)Knows tenant, plan, costAdds a store round trip per request
In-process (golang.org/x/time/rate, memory stores)No networkPer 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 GET then SET lets 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 from TIME.
  • 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-Policy header fields are still a draft and their syntax changed between revisions. Either pin to one draft revision and document it, or keep the widely understood X-RateLimit-* trio. Document whether Reset is 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.ip is the proxy unless trusted-proxy settings are configured; every client then shares one bucket. Trust only your own proxy hops; never read X-Forwarded-For blindly, 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.yamlgenerated from this page

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.md176 lines, 5.8 KB
# 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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem