← Recipes

阅读中文版本

Return Standard Rate Limit Headers

Expose RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset on every response and keep 429 bodies consistent so clients can back off.

~30 minapirate-limitinghttp

Task

Let API clients see their remaining quota and reset window before they hit a 429, instead of retrying blindly.

When to use this

Partners integrate against an API that silently returns 429. Their clients retry immediately, amplify load, and open support tickets because nothing in the response explains how long to wait or how much quota remains.

Prerequisites

  • An API with a shared counter store such as Redis
  • Middleware or gateway where response headers can be set
  • A defined quota per API key or tenant

Steps

1. Implement a fixed-window counter and set the draft rate limit headers before any early return.

app.use(async (req, res, next) => {
  const windowStart = Math.floor(Date.now() / 60_000);
  const key = `rl:${req.apiKey}:${windowStart}`;
  const used = await redis.incr(key);
  if (used === 1) await redis.expire(key, 120);

  const remaining = Math.max(0, 100 - used);
  const resetSeconds = 60 - (Math.floor(Date.now() / 1000) % 60);
  res.set({
    'RateLimit-Limit': '100',
    'RateLimit-Remaining': String(remaining),
    'RateLimit-Reset': String(resetSeconds),
  });

  if (used > 100) {
    res.set('Retry-After', String(resetSeconds));
    return res.status(429).json({ error: 'rate_limited', retry_after: resetSeconds });
  }
  next();
});

Expected output: Every response carries quota headers; the 101st request returns 429 with Retry-After matching the reset window.

2. Emit the same headers from the gateway when it rejects requests before the application runs.

limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m;

location /v1/ {
  limit_req zone=api burst=20 nodelay;
  limit_req_status 429;
  add_header RateLimit-Limit "100" always;
  add_header RateLimit-Remaining "0" always;
  add_header RateLimit-Reset "60" always;
  add_header Retry-After "60" always;
  proxy_pass http://127.0.0.1:8080;
}

Expected output: Gateway-level rejections also include rate limit headers instead of a bare 429.

3. Verify the headers across a burst of requests.

for i in $(seq 1 3); do curl -s -D - -o /dev/null https://api.example.com/v1/items | grep -i -E "ratelimit|retry-after"; done

Expected output: Each response shows a decreasing RateLimit-Remaining value and a RateLimit-Reset between 1 and 60.

4. Document the header contract in the OpenAPI description so generated clients expose it.

components:
  headers:
    RateLimitRemaining:
      schema: { type: integer }
      description: Requests left in the current window.

Expected output: Generated SDKs expose the remaining quota and clients can schedule retries from Retry-After.

Troubleshooting

Problem

Headers are missing on 429 responses

Solution

Set headers before the early return; many frameworks discard headers added after the response body is written. Use an always-on gateway directive as well.

Problem

Replicas disagree about remaining quota

Solution

Move counters into a shared Redis instance or use a gateway that enforces centrally; local in-memory counters drift per instance.

Limitations

  • The RateLimit header names come from an Internet-Draft and may still change.
  • A fixed window allows a burst at the boundary: up to twice the limit across two adjacent windows.

Tell clients what is left

A 429 without context forces clients into blind retries or exponential backoff guesses. The RateLimit header trio gives them the quota, the remaining amount, and the seconds until reset, so a well-behaved client can slow down before the rejection. Retry-After remains the authoritative signal for when to try again.

Set headers on every path

Headers must be attached before any early return, including the 429 branch and errors thrown by middleware. A common bug is adding them at the end of the success path only. Centralize header construction in one function so the gateway and application agree on names and values. When a reverse proxy enforces its own limit, it must emit the same headers, or clients will see contradictory quotas.

res.set({
  'RateLimit-Limit': '100',
  'RateLimit-Remaining': String(remaining),
  'RateLimit-Reset': String(resetSeconds),
});

Choose a counting strategy

A fixed window is simple and cheap but permits a boundary burst. A sliding window log or a token bucket smooths traffic at the cost of more state. Whatever the algorithm, keep the counter in a shared store; per-instance counters produce quotas that multiply with replica count. Include the tenant or API key in the key, and expire counters automatically.

Verify and document

Loop a few requests and watch Remaining decrease. Then exceed the quota and confirm 429 plus Retry-After. Publish the contract in OpenAPI so generated clients and partner documentation stay aligned. Monitor the ratio of 429s to successful calls; a rising ratio means either a misbehaving client or a limit set below real usage.