Practical guide
How to implement API rate limiting
Enforce the limit before expensive work and tell clients exactly when they may continue.
First, the short version
What does a rate limit protect?
Rate limiting restricts the number of requests in a time period. It protects an API against an accidental loop, an overly aggressive client, and some attacks, but it does not replace authentication or infrastructure protection.
Throttling may slow requests down or queue them; a hard limit rejects them after the quota is exhausted. Shared Redis state keeps the counter consistent across all running application instances.
Get ready
What to choose before implementation
A number without context is not a policy. Decide who and what you limit, and over which window.
- A client identity: ideally an account or API key ID. An IP address is only a supplementary limit for anonymous traffic.
- A limit scope: the whole account, a particular endpoint, or an expensive operation. Login needs a different policy from catalogue reads.
- A capacity and time window, such as 100 requests per 60 seconds, derived from real capacity and legitimate traffic.
- Shared Redis available to every application instance, plus a decision on what happens when it is unavailable.
Steps 1 to 3
Enforce the limit atomically and predictably
Start with a fixed window. It is easy to operate and explain; add a smoother token bucket only when you have a real need.
1. Define the key and limit policy
- Build the key from a policy version, stable client ID, and endpoint group name. Do not put the full secret token in the key.
- Calculate the time window on the server. A client clock or client-supplied header must not decide the limit.
- Run the rate limiter immediately after safely establishing identity and before a database query, external API, or other expensive work.
- Document both capacity and scope. A client must know whether all endpoints share the quota and whether failed requests count.
composer require predis/predis Official Redis documentation for PHP clients 2. Increment the Redis counter atomically
- For a simple fixed window, increment with INCR and set expiry on the first request. Run both operations in one Lua script so a key cannot be left without a TTL between them.
- The script returns the current count and remaining TTL. Allow the request when the count has not exceeded capacity.
- Let the Redis key expire automatically at the end of the window. No separate counter cleanup is needed.
- A fixed window permits a brief spike at the boundary of two windows. Use a sliding window or token bucket if that is a problem.
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return {current, redis.call('TTL', KEYS[1])} Official Redis rate limiting guide 3. Return the correct HTTP response
- After the quota is exceeded, return the HTTP status 429 Too Many Requests and a short error body in the same format as other API errors.
- Put the number of seconds remaining in the Retry-After header. The client then does not need to guess when to retry.
- If your ecosystem supports them, you may add the proposed RateLimit and RateLimit-Policy headers. Clients can then see capacity, remaining quota, and time until reset.
- Deliberately choose fail-open or fail-closed behaviour for a Redis outage. Login or a paid operation may need stricter behaviour than a public read.
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 17
{"error":"rate_limit_exceeded"} Current IETF draft for RateLimit HTTP headers Step 4
Verify the limit under concurrency
A sequential test cannot reveal a race between several PHP processes. Send requests concurrently and observe the shared counter.
-
Exhaust a small test quota
Set the test-environment limit to 5. The first five requests should pass and the following request should return 429 with a positive Retry-After.
seq 1 8 | xargs -n1 -P8 -I{} curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/api/products -
Verify client isolation
Exhaust the quota with one test account and call the endpoint with another. The second account should not share its counter unless the policy explicitly says so.
-
Wait for the window to reset
After Retry-After elapses, the next request must pass. Also check that the old Redis key disappeared and no keys without expiry leak.
redis-cli TTL rate-limit:v1:test-user:catalog
If something goes wrong
Common problems
Each application instance counts requests separately
Do not keep a production limit only in PHP process memory. Every instance must use the same Redis and key format.
Keys without expiry remain in Redis
Run INCR and the first EXPIRE atomically in a Lua script. Monitor TTL and the number of persistent rate-limit keys.
One user behind corporate NAT blocks everyone else
After login, limit by a stable account or API key ID. Keep the IP limit as broader supplementary protection, not the only identity.
A client sends even more requests after a 429
A retry must respect Retry-After, cap the number of attempts, and add random jitter. Otherwise, every client tries again at the same moment.
Done
The API has a shared and understandable limit.
Rate limiting now protects expensive work atomically, and clients receive the correct HTTP response. Keep tuning quotas from metrics, not guesses.