Architecture No. 325

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.

  • namerate-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

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.

~/.claude/skills/rate-limiting/SKILL.md

--- name: rate-limiting description: Designs and implements rate limits for APIs and outbound calls by choosing the algorithm (token bucket, sliding window counter, fixed… license: MIT ---

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 Requests with Retry-After when rejecting.
  • Decide explicitly whether to fail open or closed when the limiter store is down.

Algorithm choice

AlgorithmBehaviorMemory per keyUse for
Token bucketAllows bursts up to capacity, refills at a steady rateO(1)Default for public APIs; per-user/key limits
Sliding window counterWeighted blend of current and previous window; smooth, approximateO(1)Per-minute/hour limits without boundary bursts
Fixed window counterINCR + EXPIRE per windowO(1)Coarse quotas (daily caps); accepts up to 2x burst at boundaries
Sliding logSorted set of request timestamps; exactO(limit)Low-volume, high-value limits (login attempts, OTP sends)
GCRAToken-bucket equivalent storing one timestampO(1)High-throughput limiters; same semantics as token bucket
Concurrency limitCaps in-flight requests, not rateO(1)Expensive endpoints (exports, reports, LLM calls)
Leaky bucket (queue)Smooths output to a fixed rate, adds latencyQueueShaping 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

LayerTypical keyPurposeTool
Edge / CDN / WAFIP, ASN, fingerprintAbuse, scraping, DDoS-ish burstsCloudflare/CloudFront WAF rules, nginx limit_req
API gatewayAPI key, client IDPlan limits, partner fairnessGateway rate-limit plugins (Kong, Envoy, cloud API gateways)
ApplicationUser, tenant, route, actionBusiness rules (5 password attempts, 100 exports/day)Redis-backed limiter in code
OutboundProvider + credentialStay under third-party limitsClient-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-Policy fields come from an IETF draft whose syntax has changed across revisions; many APIs still send X-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 protectsStore unavailable →
Capacity or fairness for normal API trafficFail 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 (GET count, compare, INCR) races under concurrency and lets bursts through. Use one atomic script.
  • Using application server clocks across instances causes skew; use Redis TIME inside 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-For from 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

  1. Unit-test the algorithm with a controllable clock: exact boundary, burst, refill.
  2. Concurrency test: fire 2x the limit in parallel from several processes; accepted count equals the limit (± algorithm tolerance).
  3. Responses include Retry-After and the chosen rate limit headers; clients back off.
  4. 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 132 lines · 4.4 KB
# 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.

esc