Skip to Content
Course content

164: Building a Simple Rate Limiter with Redis

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When I first started building APIs, I thought rate limiting was a simple CRUD problem. I figured I'd just create a request_logs table in MySQL, count the rows for a specific user ID in the last minute, and if the count was over 60, I'd block the request. It seems logical on paper, but in a high-traffic environment, this approach is a recipe for disaster.

Why SQL Counters Fail Under Pressure

The problem is a classic race condition. Imagine two requests from the same user hitting your server at almost the exact same millisecond. Both requests trigger a SELECT COUNT(*). Both see that the user has made 59 requests. Both conclude, "Okay, 59 is less than 60, we're good to go!" Both requests then proceed and increment the count to 60. You've just allowed 61 requests through when your limit was 60.

Beyond the race condition, hitting a disk-based database for every single incoming request—just to see if you should even process that request—is an incredible waste of resources. Your database will spend more time counting rows than actually serving data. This is why we move this logic into memory, specifically using Redis.

Using Redis Atomicity to Solve the Race

Redis is single-threaded, which is its "secret sauce" for this use case. When you use the INCR command, Redis guarantees that the operation is atomic. If ten requests hit Redis at once to increment the same key, Redis will handle them one by one, ensuring the count is perfectly accurate every time.

Let's look at how to implement a "Fixed Window" rate limiter. In this scenario, we'll imagine we are protecting an expensive SMS-sending endpoint where we only allow 5 messages per minute per user to prevent spam and cost overruns.

// Assume $redis is an instance of \Redis
$userId = 'user_123';
$limit = 5;
$window = 60; // seconds
$key = "rate_limit:sms:$userId";

// Atomic increment
$currentRequests = $redis->incr($key);

// If this is the first request in the window, set the expiration
if ($currentRequests === 1) {
    $redis->expire($key, $window);
}

if ($currentRequests > $limit) {
    http_response_code(429);
    echo json_encode(['error' => 'Too many SMS requests. Please wait a minute.']);
    exit;
}

// Proceed to send the SMS...

I like this approach because it's lean. We aren't storing timestamps or complex logs; we're just storing a single integer and a TTL (Time To Live). Once the 60 seconds are up, Redis automatically wipes the key, and the user's quota resets. It's clean, fast, and most importantly, it's thread-safe.

Handling the "Edge" of the Window

You should be aware that a "Fixed Window" has one flaw: the "burst" at the boundary. If a user sends 5 requests at 10:00:59 and another 5 requests at 10:01:01, they've technically sent 10 requests in two seconds, even though they stayed within the "per minute" limit of each individual window. For most applications, this is perfectly acceptable. If you're building something like a high-frequency trading platform, you'd want a "Sliding Window," but that requires using Redis Sorted Sets, which adds significant complexity. For 95% of the projects you'll work on, the INCR method I showed you is the right tool for the job.




📋 Practical Task

Exercise: Building a Per-API-Key Rate Limiter for a Weather Service

You are tasked with protecting a Weather API. Instead of using a User ID, the system identifies clients by an api_key passed in the request header. Your goal is to implement a rate limiter that allows 10 requests per minute per API key.

Requirements:

  • Retrieve the X-API-Key from the $_SERVER superglobal.
  • If the key is missing, return a 401 Unauthorized response.
  • Implement the Redis INCR and EXPIRE logic to limit requests to 10 per 60 seconds.
  • If the limit is exceeded, return a 429 Too Many Requests response with a JSON body explaining the limit.
  • If the request is allowed, return a 200 OK response with a dummy weather payload: {"temp": "22C", "condition": "Sunny"}.

Constraint: Ensure that you only call expire() when the key is first created to avoid resetting the window on every single request.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.