Cache-based API rate limiting for PHP applications.
- Overview
- Features
- How It Works
- Installation
- Quick Start
- Advanced Usage
- Cache Adapters
- Technical Details
- Contributing
- License
composer require sanmai/rate-limiterRate limiting with the sliding window approach.
Real-world example: Imagine you need to limit API requests to 100 per minute and 1000 per hour per client. This library lets you create a rate limiter with a 1-minute window and 1-hour observation period, then check if a client exceeds either of these limits.
- Two-level limiting - Window-based and period-based limits
- Lazy evaluation - Calculates limits only when needed
- PSR-compatible - Easily integrates with PSR-15 middleware
This rate limiter provides two types of limits:
- Window limits - Controls request rates in the most recent time window (e.g., 100 requests per minute)
- Period limits - Controls total requests over a longer observation period (e.g., 1000 requests per hour)
The rate limiter itself tracks requests, while the limits are set when checking if they've been exceeded.
// Import necessary classes
use SlidingWindowCounter\RateLimiter\RateLimiter;
use SlidingWindowCounter\Cache\MemcachedAdapter;
// Create a rate limiter for an IP address with 1-minute windows and 1-hour observation period
$rateLimiter = RateLimiter::create(
'192.168.1.1', // Subject being rate limited (e.g., IP address)
'api_requests', // Name for your rate limiter
60, // Window size: 60 seconds (1 minute)
3600, // Observation period: 3600 seconds (1 hour)
new MemcachedAdapter($memcached)
);// Record a request from this client
$rateLimiter->increment();
// You can also increment by a specific amount (for weighted actions)
$rateLimiter->increment(2); // Count this action as 2 requests// Check if the client has exceeded window limit (100 requests per minute)
$windowResult = $rateLimiter->checkWindowLimit(100);
if ($windowResult->isLimitExceeded()) {
// Window limit exceeded - client is sending requests too quickly
echo $windowResult->getLimitExceededMessage();
// Example output: "Rate limit exceeded for 192.168.1.1: 120 actions in the window (limit: 100)"
// Return 429 Too Many Requests response
header('HTTP/1.1 429 Too Many Requests');
header(sprintf('Retry-After: %d', $windowResult->getWaitTimeSeconds()));
exit;
}
// Check if the client has exceeded period limit (1000 requests per hour)
$periodResult = $rateLimiter->checkPeriodLimit(1000);
if ($periodResult->isLimitExceeded()) {
// Period limit exceeded - client has sent too many requests in the observation period
echo $periodResult->getLimitExceededMessage();
// Return 429 Too Many Requests response
header('HTTP/1.1 429 Too Many Requests');
header(sprintf('Retry-After: %d', $periodResult->getWaitTimeSeconds()));
exit;
}// Get information about the current rate limit status
$windowResult = $rateLimiter->checkWindowLimit(100);
// Subject being rate limited
$subject = $windowResult->getSubject(); // e.g., "192.168.1.1"
// Current count in the window
$count = $windowResult->getCount();
// Maximum limit
$limit = $windowResult->getLimit();
// Type of limit
$limitType = $windowResult->getLimitType(); // "window" or "period"
// Get the limit message (only if exceeded)
$message = $windowResult->getLimitExceededMessage();
// Get wait time in seconds (rounded up) - useful for Retry-After header
$waitSeconds = $windowResult->getWaitTimeSeconds();
// Get wait time in nanoseconds - useful for precise sleeping
$waitNanoseconds = $windowResult->getWaitTime();
// Get wait time with jitter to avoid thundering herd (0.5 = up to 50% extra delay)
$waitWithJitter = $windowResult->getWaitTime(0.5);
// Get the latest value in the current window
$currentValue = $rateLimiter->getLatestValue();
// Get the total across all windows in the observation period
$totalRequests = $rateLimiter->getTotal();You can create different rate limiters for different types of constraints:
// General rate limiter with 1-minute windows and 1-hour observation period
$generalLimiter = RateLimiter::create($clientIp, 'general_api', 60, 3600, $cache);
// Check if client exceeds 100 requests per minute
$windowResult = $generalLimiter->checkWindowLimit(100);
// Check if client exceeds 1000 requests per hour
$periodResult = $generalLimiter->checkPeriodLimit(1000);
// Stricter limiter for sensitive endpoints with same time parameters
$sensitiveLimiter = RateLimiter::create($clientIp, 'sensitive_api', 60, 3600, $cache);
// Check if client exceeds 10 requests per minute for sensitive endpoints
$sensitiveWindowResult = $sensitiveLimiter->checkWindowLimit(10);
// Check if client exceeds 50 requests per hour for sensitive endpoints
$sensitivePeriodResult = $sensitiveLimiter->checkPeriodLimit(50);When you control both ends (e.g., a background job calling your own API), you can use the wait time to self-throttle instead of failing:
use DuoClock\DuoClock;
$clock = new DuoClock();
$rateLimiter = RateLimiter::create($jobId, 'batch_processing', 60, 3600, $cache);
foreach ($items as $item) {
$rateLimiter->increment();
$result = $rateLimiter->checkWindowLimit(100);
if ($result->isLimitExceeded()) {
// Wait until the rate limit resets using DuoClock's nanosleep
$clock->nanosleep($result->getWaitTime());
}
processItem($item);
}If you're not using DuoClock, you can use PHP's time_nanosleep() directly:
$ns = $result->getWaitTime();
time_nanosleep(intdiv($ns, 1_000_000_000), $ns % 1_000_000_000);When multiple workers compete for the same rate limit, use jitter to spread out retries and avoid thundering herd:
$result = $rateLimiter->checkWindowLimit(100);
if ($result->isLimitExceeded()) {
// Add up to 50% random delay to spread out competing workers
$clock->nanosleep($result->getWaitTime(0.5));
}Note on wait time calculation: The wait time assumes a uniform distribution of requests across the window. If requests are bursty (clustered at the start or end of the window), the actual required wait time may differ. For most use cases this approximation works well.
Here's how you might implement rate limiting in a PSR-15 middleware:
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$ip = $request->getServerParams()['REMOTE_ADDR'];
// Create rate limiter
$rateLimiter = RateLimiter::create($ip, 'api_requests', 60, 3600, $this->cache);
// Increment the counter
$rateLimiter->increment();
// Check window limit (e.g., 100 requests per minute)
$windowResult = $rateLimiter->checkWindowLimit(100);
if ($windowResult->isLimitExceeded()) {
return $this->createRateLimitResponse(
$windowResult->getLimitExceededMessage(),
$windowResult->getWaitTimeSeconds()
);
}
// Check period limit (e.g., 1000 requests per hour)
$periodResult = $rateLimiter->checkPeriodLimit(1000);
if ($periodResult->isLimitExceeded()) {
return $this->createRateLimitResponse(
$periodResult->getLimitExceededMessage(),
$periodResult->getWaitTimeSeconds()
);
}
// Limits not exceeded, continue with the request
return $handler->handle($request);
}Here are some common scenarios and how to handle them:
try {
// Create the rate limiter
$rateLimiter = RateLimiter::create($ip, 'api_requests', 60, 3600, $cache);
// Increment and check limits
$rateLimiter->increment();
$windowResult = $rateLimiter->checkWindowLimit(100);
// Handle rate limit exceeded
if ($windowResult->isLimitExceeded()) {
// Log the rate limit event
$this->logger->warning('Rate limit exceeded', [
'ip' => $ip,
'count' => $windowResult->getCount(),
'limit' => $windowResult->getLimit(),
'type' => $windowResult->getLimitType()
]);
// Return appropriate response with calculated wait time
return $this->createRateLimitResponse(
$windowResult->getLimitExceededMessage(),
$windowResult->getWaitTimeSeconds()
);
}
} catch (Exception $e) {
// If the cache service is unavailable, fail open (allow the request)
$this->logger->error('Rate limiter error', ['exception' => $e]);
// Continue processing the request
return $handler->handle($request);
}This library uses the cache adapters provided by the sanmai/sliding-window-counter library. For information about available adapters and how to create your own, please refer to the sliding window counter documentation.
See the LICENSE file for details.