How to Build a Robust Token‑Bucket Rate Limiter in Webman with Redis Lua

This article walks through the design, pitfalls, and step‑by‑step implementation of a token‑bucket rate‑limiting middleware for the Webman PHP framework, highlighting the need for atomic Redis operations and showing how a Lua script resolves concurrency issues in distributed environments.

Open Source Tech Hub
Open Source Tech Hub
Open Source Tech Hub
How to Build a Robust Token‑Bucket Rate Limiter in Webman with Redis Lua

Background

A new "Product 2.0" API built with Webman went live and immediately attracted massive traffic from a promotional campaign. The sudden surge caused MySQL connection‑pool overflow, Redis slow‑log spikes, and HTTP 503/504 errors.

Problem

The API had no traffic‑control mechanism, so a burst of requests overwhelmed the system. Simple scaling or caching was insufficient; an emergency rate‑limiting “brake” was required.

Why Webman Is Ideal for Rate Limiting

Webman runs as a long‑living, event‑driven process with coroutine support (via Swoole). Middleware executes with negligible latency, making it a lightweight host for rate‑limiting logic.

Common Rate‑Limiting Algorithms

Fixed Window Counter : Simple but cannot handle short spikes; a burst can exhaust the whole window.

Sliding Window Counter : Divides the window into smaller slots for smoother counting, yet still suffers from burst inaccuracies.

Leaky Bucket : Shapes traffic to a constant outflow rate, but is rigid for sudden bursts.

Token Bucket : Allows bursts by accumulating tokens up to a capacity and consuming one per request; best suited for the described scenario.

Initial Implementation (Non‑Atomic)

The first version created a TokenBucketMiddleware.php that fetched the current token count and last refill timestamp from Redis, calculated new tokens, and wrote the updated values back. The TokenBucket.php class performed the same logic.

<?php
namespace app\middleware;
use Webman\MiddlewareInterface;
use Webman\Http\Response;
use Webman\Http\Request;
class TokenBucketMiddleware implements MiddlewareInterface {
    public function process(Request $request, callable $next): Response {
        $path = $request->path();
        if ($path === '/api/products/hot') {
            $key = 'ratelimit:' . $path;
            $capacity = 50;
            $ratePerSecond = 10;
            $bucket = new TokenBucket();
            if (!$bucket->consume($key, $capacity, $ratePerSecond)) {
                return new Response(429, ['Content-Type' => 'application/json'], json_encode(['code'=>429,'message'=>'Too Many Requests']));
            }
        }
        return $next($request);
    }
}
<?php
namespace app\service;
use support\Redis;
class TokenBucket {
    public function consume(string $key, int $capacity, float $ratePerSecond): bool {
        $redis = Redis::connection('default');
        // 1. Get current token count
        $currentTokens = $redis->get($key);
        if ($currentTokens === null) $currentTokens = $capacity; else $currentTokens = (float)$currentTokens;
        // 2. Get last refill timestamp
        $lastTimeKey = $key . ':last_time';
        $lastRefillTime = $redis->get($lastTimeKey);
        if ($lastRefillTime === null) $lastRefillTime = microtime(true); else $lastRefillTime = (float)$lastRefillTime;
        // 3. Compute new tokens
        $currentTime = microtime(true);
        $timePassed = $currentTime - $lastRefillTime;
        $tokensToAdd = $timePassed * $ratePerSecond;
        $newTokenCount = min($capacity, $currentTokens + $tokensToAdd);
        // 4. Check and consume
        if ($newTokenCount < 1) return false;
        $newTokenCount -= 1;
        // 5. Update Redis
        $redis->set($key, $newTokenCount);
        $redis->set($lastTimeKey, $currentTime);
        return true;
    }
}

Under load, this non‑atomic approach allowed multiple workers to read the same token count, compute the same new value, and overwrite each other, resulting in severe over‑release of tokens and a broken limiter.

Root Cause: Lack of Atomicity

Each request performed a read‑modify‑write sequence consisting of separate Redis commands. Although individual Redis commands are atomic, the whole sequence is not, so concurrent workers could interleave operations, leading to state inconsistency and token “overselling”.

Atomic Solution with Lua Scripting

Redis Lua scripts execute as a single command, guaranteeing that all reads, calculations, and writes happen without interruption. The following script implements the entire token‑bucket logic atomically.

-- token_bucket.lua
local key = KEYS[1]
local lastTimeKey = KEYS[2]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = 1
local currentTokens = tonumber(redis.call('GET', key))
if currentTokens == nil then currentTokens = capacity end
local lastRefillTime = tonumber(redis.call('GET', lastTimeKey))
if lastRefillTime == nil then lastRefillTime = now end
local timePassed = now - lastRefillTime
local tokensToAdd = timePassed * rate
local newTokenCount = math.min(capacity, currentTokens + tokensToAdd)
if newTokenCount < requested then return 0 end
newTokenCount = newTokenCount - requested
redis.call('SET', key, newTokenCount)
redis.call('SET', lastTimeKey, now)
return 1

The PHP TokenBucket class now loads this script and executes it via eval, passing the key, timestamp key, capacity, rate, and a synchronized timestamp.

public function consume(string $key, int $capacity, float $ratePerSecond): bool {
    $redis = Redis::connection('default');
    $script = file_get_contents(base_path('storage/scripts/token_bucket.lua'));
    $lastTimeKey = $key . ':last_time';
    $now = microtime(true);
    $result = $redis->eval($script, [$key, $lastTimeKey], 2, $capacity, $ratePerSecond, $now);
    return $result === 1;
}

After deploying the Lua‑based limiter, stress tests with 1000 concurrent requests for one minute showed stable behavior: the API consistently allowed about 10 requests per second, returned HTTP 429 for excess traffic, and kept CPU and database connections within safe limits.

Additional Distributed Pitfalls

Even with atomic scripts, time synchronization across workers is crucial. If servers have clock drift, the $now parameter can cause token generation to be too aggressive or too strict. Using NTP or a single Redis‑based time source (e.g., Redis::time()) ensures a consistent timestamp.

Takeaways

Rate limiting in high‑traffic services must be atomic to avoid token over‑release.

Redis Lua scripting provides a simple, performant way to achieve this atomicity.

Proper time synchronization is essential for distributed token‑bucket algorithms.

Webman’s coroutine‑friendly architecture makes it an excellent host for lightweight middleware such as this limiter.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

redisPHPrate limitingLuaToken BucketWebman
Open Source Tech Hub
Written by

Open Source Tech Hub

Sharing cutting-edge internet technologies and practical AI resources.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.