Skip to Content
Course content

141: Practice Exercise: Building a Rate Limiter Middleware

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

You've likely reached a point in your project where you're worried about someone (or some bot) hammering your API endpoints and crashing your server. A rate limiter middleware is the standard way to handle this. But when I first started writing these, I made a classic mistake that effectively turned my rate limiter into a permanent ban hammer.

Take a look at this snippet. It looks reasonable at first glance: it checks a cache for the user's IP, increments the count, and blocks them if they've hit the limit.

public function handle($request, Closure $next) 
{
    $ip = $request->ip();
    $key = "rate_limit:" . $ip;
    $requests = Cache::get($key, 0);

    if ($requests >= 60) {
        return response('Too Many Requests', 429);
    }

    Cache::put($key, $requests + 1);
    return $next($request);
}

The Permanent Ban Bug

If you run this code, it will work perfectly for the first 60 requests. But once a user hits that 61st request, they are blocked. And here is the kicker: they are blocked forever. Or at least, until you manually clear your cache.

The problem is that I'm using Cache::put() without an expiration time. In a rate limiter, the "window" is everything. If you're allowing 60 requests per minute, that counter needs to vanish after 60 seconds. Because I didn't set a Time-To-Live (TTL), the key stays in Redis or Memcached indefinitely. The user hits 60, the count stops incrementing because they're blocked, but the value never drops back to 0.

Implementing a Sliding Window with TTL

To fix this, we need to ensure the key expires. However, simply adding a timeout to put() every time is also problematic because it resets the timer on every single request, potentially giving the user an infinite window if they keep requesting just under the limit.

The professional way to handle this is to check if the key exists first. If it doesn't, we set the initial value and the expiration. If it does, we just increment it. Here is how I'd actually write this to be robust:

public function handle($request, Closure $next) 
{
    $ip = $request->ip();
    $key = "rate_limit:" . $ip;
    $limit = 60;
    $decayMinutes = 1;

    // Use atomic increments if your cache driver supports it
    $current = Cache::get($key);

    if ($current && $current >= $limit) {
        return response('Too Many Requests', 429);
    }

    if (!$current) {
        // First request in this window: set the value and the expiration
        Cache::put($key, 1, now()->addMinutes($decayMinutes));
    } else {
        // Subsequent requests: just increment
        Cache::increment($key);
    }

    return $next($request);
}

I switched to Cache::increment() because it's atomic. In a high-traffic environment, if two requests hit at the exact same millisecond, a standard get and put cycle can suffer from a race condition where both requests read "5", both add 1, and both write "6"—effectively losing one count. increment() happens inside the cache store, ensuring no request is missed.

One thing to keep in mind: using the IP address is a great start, but in the real world, many users might share a single public IP (like in a corporate office). If you're building something for a paid service, you'll want to swap $request->ip() for a user_id from the authenticated session.




📋 Practical Task

Exercise: Building a Tiered API Rate Limiter

Your goal is to expand the basic rate limiter into a tiered system. Instead of a flat limit for everyone, you need to implement a middleware that checks the user's subscription level and applies different limits accordingly.

Requirements:

  • Create a middleware that identifies if a user is 'guest', 'basic', or 'premium'. (For the sake of this exercise, you can simulate this by checking a 'plan' attribute on the User object or a custom header in the request).
  • Apply the following limits per minute:
    • Guest: 10 requests
    • Basic: 60 requests
    • Premium: 1000 requests
  • The middleware must use a cache store with a 1-minute expiration.
  • If the limit is exceeded, return a 429 response, but include a custom header X-RateLimit-Limit telling the user what their specific limit was.
  • Ensure you use atomic increments to prevent race conditions.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.