Rate Limiting skill for Claude Code
Atomic limits, honest headers, clients that back off. Claude chooses the algorithm, key and enforcement layer for each goal, whether that is abuse at the edge, plan limits at the gateway or business rules in the app, and implements distributed limits as single atomic Redis scripts using server time. Rejections return 429 with Retry-After, limits roll out in shadow mode first, and fail-open or fail-closed is decided per limit.
- name
rate-limiting - size167 lines · ≈1,950 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs and implements rate limits for APIs and outbound calls by choosing the algorithm (token bucket, sliding window counter, fixed window, concurrency limit), the key (IP, API key, user, tenant, route), and the enforcement layer (edge, gateway, app), and implements distributed limits atomically in Redis with Lua. Covers 429 responses, Retry-After and RateLimit headers, fail-open vs. fail-closed, plan-based quotas, and client-side backoff against third-party limits. Use when adding rate limiting or throttling to an API, protecting login or expensive endpoints, hitting 429s from a provider, or reviewing a limiter implementation. Try asking
- Add per-API-key rate limiting to our Express API backed by Redis
- Protect the login endpoint from brute force without locking out offices behind NAT
- We keep getting 429s from the Shopify API. Make our client pace itself
Use it when
- Adding rate limits or quotas to an API
- Protecting login, OTP, or expensive endpoints
- A third-party API is returning 429 responses
- Reviewing a limiter for race conditions
Skip it when
- You need retry and backoff policy for failed calls in general: use idempotency-retries
- You are configuring nginx or Caddy beyond rate limits: use nginx-reverse-proxy
How to check the result
A parallel burst of twice the limit from several processes accepts only the limit, rejected responses carry Retry-After, and stopping the limiter store triggers the chosen fail-open or fail-closed behavior.
The common mistake it prevents
Reading a counter, comparing it in application code and then incrementing it, which races under concurrency and lets bursts far above the limit through.
A good rate limiter protects capacity and fairness without surprising legitimate clients: limits are derived from real capacity and usage, enforced atomically across instances, communicated through standard headers, and paired with clients that back off. A limiter that races under concurrency or resets on deploy is decoration.
Ground rules
- Decide what you are protecting (capacity, cost, abuse, fairness between tenants) before picking numbers. Different goals need different keys and layers.
- Derive limits from data: current p99 usage per key and backend capacity. Start generous, log would-be rejections in shadow mode, then enforce.
- Distributed limits must be atomic: a single Lua script or a single atomic command, never read-then-write from the application.
- Always return
429 Too Many RequestswithRetry-Afterwhen rejecting. - Decide explicitly whether to fail open or closed when the limiter store is down.
Algorithm choice
| Algorithm | Behavior | Memory per key | Use for |
|---|---|---|---|
| Token bucket | Allows bursts up to capacity, refills at a steady rate | O(1) | Default for public APIs; per-user/key limits |
| Sliding window counter | Weighted blend of current and previous window; smooth, approximate | O(1) | Per-minute/hour limits without boundary bursts |
| Fixed window counter | INCR + EXPIRE per window | O(1) | Coarse quotas (daily caps); accepts up to 2x burst at boundaries |
| Sliding log | Sorted set of request timestamps; exact | O(limit) | Low-volume, high-value limits (login attempts, OTP sends) |
| GCRA | Token-bucket equivalent storing one timestamp | O(1) | High-throughput limiters; same semantics as token bucket |
| Concurrency limit | Caps in-flight requests, not rate | O(1) | Expensive endpoints (exports, reports, LLM calls) |
| Leaky bucket (queue) | Smooths output to a fixed rate, adds latency | Queue | Shaping outbound calls to a strict provider |
Workflow
- [ ] 1. State goals and pick keys and layers
- [ ] 2. Measure current usage per key; set limits with headroom
- [ ] 3. Choose algorithm per limit
- [ ] 4. Implement atomically (gateway config or Redis Lua)
- [ ] 5. Return 429 + Retry-After + rate limit headers
- [ ] 6. Ship in shadow mode, review would-be rejections, then enforce
- [ ] 7. Monitor rejections per key and route; alert on spikes
1. Keys and layers
| Layer | Typical key | Purpose | Tool |
|---|---|---|---|
| Edge / CDN / WAF | IP, ASN, fingerprint | Abuse, scraping, DDoS-ish bursts | Cloudflare/CloudFront WAF rules, nginx limit_req |
| API gateway | API key, client ID | Plan limits, partner fairness | Gateway rate-limit plugins (Kong, Envoy, cloud API gateways) |
| Application | User, tenant, route, action | Business rules (5 password attempts, 100 exports/day) | Redis-backed limiter in code |
| Outbound | Provider + credential | Stay under third-party limits | Client-side token bucket + backoff |
Layer them: coarse IP limits at the edge, precise per-key limits in the app. IP-only limits punish users behind shared NAT and are bypassed by rotating IPs; combine with authenticated keys wherever possible.
2. Setting numbers
- Pull per-key request rates for the last 2–4 weeks; set the limit above the p99 key’s peak (often 2–5x) so normal heavy users never hit it.
- Bucket capacity (burst) covers legitimate bursts such as page loads that fire 20 requests at once.
- Expensive routes get their own lower limits, not the global one.
- Per-plan limits live in config, not code, so sales can change them.
4. Implementation
nginx (per IP, edge-style):
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
}
}
Application with Redis: use an atomic Lua script. Token bucket and sliding window counter scripts, with notes on Redis Cluster key slots and server time, are in references/redis-limiters.md; read it before writing a Redis limiter.
// usage sketch with ioredis
redis.defineCommand('tokenBucket', { numberOfKeys: 1, lua: TOKEN_BUCKET_LUA });
export async function limit(key: string, ratePerSec: number, capacity: number) {
const [allowed, remaining, retryAfterMs] = await (redis as any).tokenBucket(
`rl:{${key}}`, ratePerSec, capacity, 1);
return { allowed: allowed === 1, remaining: Math.floor(Number(remaining)), retryAfterMs: Number(retryAfterMs) };
}
Libraries exist for most frameworks (e.g., express-rate-limit with a Redis store,
django-ratelimit, Spring/Bucket4j, ASP.NET Core rate limiting middleware). Use one if
it supports an atomic shared store and your algorithm; check how it computes keys behind
proxies (X-Forwarded-For trust settings).
5. Response contract
HTTP/1.1 429 Too Many Requests
Retry-After: 12
RateLimit-Policy: "default";q=100;w=60
RateLimit: "default";r=0;t=12
Content-Type: application/problem+json
{"type":"https://api.example.com/problems/rate-limited","title":"Too many requests","status":429,"detail":"Limit of 100 requests per 60 s exceeded; retry in 12 s."}
Retry-After(seconds or HTTP date) is the standard clients and SDKs honor.- The
RateLimit/RateLimit-Policyfields come from an IETF draft whose syntax has changed across revisions; many APIs still sendX-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset. Pick one convention, document it, and send it on successful responses too so clients can pace themselves. - Use a distinct status for quota exhaustion vs. rate (both 429, different problem
type), so clients know whether waiting seconds or upgrading a plan helps.
Outbound: respecting provider limits
- Read the provider’s documented limits and headers; pace with a client-side token bucket per credential so you rarely receive 429s.
- On 429/503: honor
Retry-After; otherwise exponential backoff with full jitter and a cap (see idempotency-retries). - Queue bulk work and drain at the allowed rate instead of bursting.
Fail open or closed?
| Limit protects | Store unavailable → |
|---|---|
| Capacity or fairness for normal API traffic | Fail open (allow), alert loudly, keep a local in-memory fallback limit |
| Security (login attempts, OTP, password reset) | Fail closed or fall back to a strict local limit |
| Cost (paid LLM calls, SMS) | Fail closed |
Set a short timeout (e.g., 20–50 ms) on limiter calls so a slow Redis does not become the outage.
Gotchas
- Read-then-write (
GETcount, compare,INCR) races under concurrency and lets bursts through. Use one atomic script. - Using application server clocks across instances causes skew; use Redis
TIMEinside the script or a single time source. - Fixed windows allow double the limit across a boundary (end of one minute + start of next).
- Trusting
X-Forwarded-Forfrom anywhere lets clients pick their own IP key; trust only your proxy hops. - Lua returns numbers to Redis as integers (truncated); return fractional values as strings.
- Limits per instance (in-memory) multiply with the instance count after autoscaling.
- Retries from your own clients count against limits; retry storms can lock out a whole tenant.
Verification
- Unit-test the algorithm with a controllable clock: exact boundary, burst, refill.
- Concurrency test: fire 2x the limit in parallel from several processes; accepted count equals the limit (± algorithm tolerance).
- Responses include
Retry-Afterand the chosen rate limit headers; clients back off. - Kill the limiter store in staging and confirm the chosen fail-open/closed behavior.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/redis-limiters.md
# Redis limiter scripts
Read this before implementing a distributed rate limiter on Redis (or a compatible store
such as Valkey). Both scripts are atomic, use the server clock, and touch one key, so
they work on Redis Cluster.
## Conventions
- Put the identity in a hash tag so related keys land in one slot: `rl:{user:42}:search`.
- Load once with `SCRIPT LOAD` and call with `EVALSHA` (client libraries do this for
you: ioredis `defineCommand`, redis-py `register_script`, go-redis `NewScript`).
- `redis.call('TIME')` is safe in scripts on Redis 5+ (effects replication; the only
mode in Redis 7+). It avoids skew between application servers.
- Return fractional numbers as strings; Redis truncates Lua numbers to integers.
## Token bucket
Arguments: `KEYS[1]` bucket key; `ARGV[1]` refill rate in tokens per second;
`ARGV[2]` capacity (max burst); `ARGV[3]` cost of this request (usually 1).
Returns `{allowed (1/0), tokens_remaining (string), retry_after_ms}`.
```lua
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000) -- ms
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
local elapsed = math.max(0, now - ts) / 1000
tokens = math.min(capacity, tokens + elapsed * rate)
local allowed, retry_ms = 0, 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
else
retry_ms = math.ceil((cost - tokens) / rate * 1000)
end
redis.call('HSET', key, 'tokens', tostring(tokens), 'ts', now)
redis.call('PEXPIRE', key, math.ceil(capacity / rate * 1000) + 1000)
return { allowed, tostring(tokens), retry_ms }
```
Map to headers: limit = capacity, remaining = floor(tokens), reset/Retry-After =
ceil(retry_ms / 1000) when rejected.
## Sliding window counter
Arguments: `KEYS[1]` key; `ARGV[1]` limit per window; `ARGV[2]` window length in whole
seconds; `ARGV[3]` cost. Returns `{allowed, remaining, retry_after_s}`.
The estimate weights the previous window's count by how much of it still overlaps the
sliding window: `estimate = prev * (1 - elapsed_fraction) + current`.
```lua
local key = KEYS[1]
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 data = redis.call('HMGET', key, 'start', 'cur', 'prev')
local start = tonumber(data[1])
local cur = tonumber(data[2]) or 0
local prev = tonumber(data[3]) or 0
if start == nil then
cur, prev = 0, 0
elseif start ~= cur_start then
if start == cur_start - window then prev = cur else prev = 0 end
cur = 0
end
local frac = (now - cur_start) / window
local estimate = prev * (1 - frac) + cur
local allowed, retry = 0, 0
if estimate + cost <= limit then
cur = cur + cost
estimate = estimate + cost
allowed = 1
else
retry = math.ceil(cur_start + window - now) -- conservative: end of current window
end
redis.call('HSET', key, 'start', cur_start, 'cur', cur, 'prev', prev)
redis.call('EXPIRE', key, window * 2)
return { allowed, math.max(0, math.floor(limit - estimate)), retry }
```
## Fixed window (daily quotas)
No script needed when a simple counter suffices:
```
INCR quota:{tenant:7}:2026-03-14
EXPIRE quota:{tenant:7}:2026-03-14 90000 NX # NX option needs Redis 7+
```
Compare the returned count with the quota. On Redis < 7, wrap both in a short script so
the expiry is set only when the key is new.
## Sliding log (exact, low volume)
For login or OTP limits where exactness matters and volume is small:
```
ZREMRANGEBYSCORE key 0 (now_ms - window_ms)
ZCARD key → reject if >= limit
ZADD key now_ms <unique-member>
PEXPIRE key window_ms
```
Run these inside one Lua script (using `TIME`) so the check and the add are atomic.
Memory grows with the limit per key, so keep limits small.
## Testing the scripts
- Run against a real Redis in a container; drive time by sleeping in coarse steps or by
temporarily passing `now` as an argument in a test-only variant of the script.
- Concurrency test: N parallel clients x M requests; total accepted must not exceed the
limit plus refill during the test window.
- Check key TTLs exist after calls (`PTTL key` > 0) so idle keys expire.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.