Rate Limiting and DDoS Mitigation Architecture: Implementing Token Bucket and Leaky Bucket Algorithms in Redis and PHP

Introduction

Protecting backend services and data stores from abuse, resource starvation, and Denial-of-Service (DoS) attacks is a core requirement of API architecture. Unthrottled public endpoints invite automated scraping, brute-force credential stuffing, and severe performance degradation when traffic spikes uncontrollably.

To enforce fair resource allocation and preserve service availability, high-throughput backend infrastructure relies on Distributed Rate Limiting Engines. By controlling the rate of incoming HTTP requests per IP address, user account, or API key, systems gracefully absorb sudden traffic spikes while rejecting malicious traffic bursts at the perimeter layer. In this technical deep-dive, we will explore the mathematical foundation behind rate limiting, compare the Token Bucket and Leaky Bucket algorithms, and build an atomic, atomic Redis-backed rate limiter using PHP and Lua scripting. For further context on securing stateless API endpoints, review our previous guide on OAuth2 and OpenID Connect Architecture, or refer to the official Redis Documentation on Programmability and Lua Scripting for details on atomic server-side operations.

Algorithm Paradigms: Token Bucket vs. Leaky Bucket

Architecting an effective rate limiter requires selecting an algorithmic strategy tailored to your workload profile:

  • Token Bucket Algorithm: A bucket holds a maximum capacity of tokens. Tokens are continuously added at a fixed replenishment rate (e.g., 10 tokens per second). When an HTTP request arrives, it consumes a token. If the bucket is empty, the request is rejected with an HTTP 429 Too Many Requests status code. Key Advantage: Allows controlled bursts of traffic up to the bucket capacity while maintaining a steady refill baseline.
  • Leaky Bucket Algorithm: Requests enter a FIFO (First-In, First-Out) queue bucket and are processed at a constant, fixed output rate regardless of incoming traffic density. If incoming requests overflow the bucket capacity, the excess traffic drops immediately. Key Advantage: Smooths out erratic traffic bursts completely, creating a predictable, steady processing pace ideal for downstream services with strict resource bounds.

Race Conditions & Atomic Operations in Distributed Systems

In distributed environments with multiple web application nodes querying a shared cache store, a naive “read-then-write” rate limiting pattern introduces dangerous race conditions:

  1. Node A reads the current request count for IP 192.168.1.100 (Value: 99 / Max: 100).
  2. Node B simultaneously reads the request count for the same IP (Value: 99 / Max: 100).
  3. Both nodes evaluate 99 < 100 as valid, process the request, and increment the counter to 100.
  4. Result: 101 requests executed—violating the configured maximum capacity.

To eliminate read-modify-write race conditions without introducing blocking database locks, rate limiting operations must execute atomically on the Redis server using embedded Lua scripts.

Prerequisites

To deploy this Redis-backed Token Bucket rate limiter, ensure your setup meets the following prerequisites:

  • PHP 8.2 or higher CLI runtime environment.
  • Redis Server 6.0 or higher with the redis PHP extension installed.

Step-by-Step Implementation: Atomic Token Bucket Rate Limiter

Below is a production-grade implementation of the Token Bucket algorithm in PHP, utilizing an atomic Redis Lua script to maintain thread safety under heavy concurrent load.

<?php

namespace App\Security;

use Redis;
use RuntimeException;

class RedisTokenBucketLimiter
{
    private Redis $redis;

    public function __construct(Redis $redisConnection)
    {
        $this->redis = $redisConnection;
    }

    /**
     * Consume tokens atomically using inline Lua script execution
     *
     * @param string $clientKey Unique identifier (e.g., "rate:ip:192.168.1.1")
     * @param int $maxCapacity Maximum token capacity of the bucket
     * @param float $fillRatePerSec Token replenishment rate per second
     * @param int $tokensToConsume Tokens requested by current request
     * @return array Status containing allowed status, remaining tokens, and retry delay
     */
    public function consume(
        string $clientKey,
        int $maxCapacity,
        float $fillRatePerSec,
        int $tokensToConsume = 1
    ): array {
        // Lua Script: Executes atomically within Redis memory space
        $luaScript = <<<'LUA'
            local key = KEYS[1]
            local max_capacity = tonumber(ARGV[1])
            local fill_rate = tonumber(ARGV[2])
            local requested = tonumber(ARGV[3])
            local now = tonumber(ARGV[4])

            -- Fetch stored bucket state: [1] = last_updated_timestamp, [2] = current_tokens
            local bucket = redis.call('HMGET', key, 'last_updated', 'tokens')
            local last_updated = tonumber(bucket[1])
            local current_tokens = tonumber(bucket[2])

            if last_updated == nil then
                -- First access: Initialize full bucket
                current_tokens = max_capacity
                last_updated = now
            else
                -- Replenish tokens based on elapsed time delta
                local delta = math.max(0, now - last_updated)
                local tokens_to_add = delta * fill_rate
                current_tokens = math.min(max_capacity, current_tokens + tokens_to_add)
                last_updated = now
            end

            local allowed = 0
            local retry_after = 0

            if current_tokens >= requested then
                allowed = 1
                current_tokens = current_tokens - requested
            else
                local missing_tokens = requested - current_tokens
                retry_after = math.ceil(missing_tokens / fill_rate)
            end

            -- Persist updated bucket state with auto-expiration TTL to prevent memory leaks
            redis.call('HMSET', key, 'tokens', current_tokens, 'last_updated', last_updated)
            local ttl = math.ceil(max_capacity / fill_rate)
            redis.call('EXPIRE', key, ttl)

            return { allowed, math.floor(current_tokens), retry_after }
LUA;

        $now = microtime(true);

        /** @var array $result */
        $result = $this->redis->eval(
            $luaScript,
            [$clientKey, $maxCapacity, $fillRatePerSec, $tokensToConsume, $now],
            1
        );

        if (!is_array($result)) {
            throw new RuntimeException("Rate limiter evaluation failed on Redis engine.");
        }

        return [
            'allowed' => (bool)$result[0],
            'remaining_tokens' => (int)$result[1],
            'retry_after_seconds' => (int)$result[2],
        ];
    }
}

// --- RUNTIME DEMONSTRATION ---
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$limiter = new RedisTokenBucketLimiter($redis);

$clientIp = '192.168.1.45';
$rateKey = "rate_limit:ip:{$clientIp}";

// Configure bucket: Max 10 tokens, replenishes at 2 tokens per second
$decision = $limiter->consume($rateKey, maxCapacity: 10, fillRatePerSec: 2.0);

if ($decision['allowed']) {
    echo "✅ [ALLOWED] Request processed. Tokens remaining: {$decision['remaining_tokens']}\n";
} else {
    http_response_code(429);
    header("Retry-After: {$decision['retry_after_seconds']}");
    echo "🚨 [TOO MANY REQUESTS] Rate limit exceeded. Retry after {$decision['retry_after_seconds']} seconds.\n";
}

Production Best Practices for Perimeter Defense

Deploying robust rate limiters across multi-region edge environments requires adhering to these operational security guidelines:

  • Standardized HTTP Headers: Always expose rate limit status back to API clients using standard IETF draft response headers: X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After.
  • Layered Limiting Key Hierarchy: Apply rate limits across multiple dimensions simultaneously. For example, combine a lenient IP-based global perimeter limit (e.g., 1000 req/min) with a strict user-ID or API key limit (e.g., 60 req/min on sensitive payment routes).
  • Fail-Open vs. Fail-Closed Mitigation Strategy: Decide how your system behaves during Redis cluster outages. Non-critical applications should “fail open” to prioritize availability, whereas high-security financial or authentication endpoints must “fail closed” to prevent exploitation.

Conclusion

Rate limiting is a fundamental pillar of modern infrastructure defense. By leveraging the Token Bucket algorithm executed via atomic Redis Lua scripts, distributed applications can effectively neutralize malicious traffic spikes, protect downstream databases from starvation, and provide uniform API service quality under heavy load.