Skip to content

RateLimiter

Ray Fung edited this page Feb 26, 2026 · 3 revisions

Rate Limiter

Razy provides a fixed-window rate limiting system with pluggable storage backends, named limiters, middleware support, and HTTP headers. The rate limiter is designed for API protection, brute-force prevention, and request throttling.


Table of Contents


Quick Start

use Razy\RateLimit\RateLimiter;

use Razy\RateLimit\Limit;

use Razy\RateLimit\Store\ArrayStore;



$limiter = new RateLimiter(new ArrayStore());



// Simple rate check

$key = 'login:' . $_SERVER['REMOTE_ADDR'];

if (!$limiter->attempt($key, maxAttempts: 5, decaySeconds: 60)) {

    http_response_code(429);

    echo "Too many attempts. Try again in {$limiter->availableIn($key)} seconds.";

    exit;

}



// Process the request...

Basic Rate Limiting

attempt()

The primary method — checks if an attempt is allowed and records it in one call:

$key = 'api:' . $userId;



if ($limiter->attempt($key, maxAttempts: 100, decaySeconds: 3600)) {

    // Request is allowed → proceed

    $remaining = $limiter->remaining($key, maxAttempts: 100);

    echo "You have {$remaining} requests remaining.";

} else {

    // Rate limit exceeded

    $retryIn = $limiter->availableIn($key);

    echo "Rate limit exceeded. Try again in {$retryIn} seconds.";

}

Manual Control

For finer control, use hit() and tooManyAttempts() separately:

// Check without recording (read-only)

if ($limiter->tooManyAttempts($key, maxAttempts: 10)) {

    // Already exceeded → don't process

    return;

}



// Record a hit manually

$totalHits = $limiter->hit($key, decaySeconds: 60);



// Read current state

$currentAttempts = $limiter->attempts($key);

$remaining = $limiter->remaining($key, 10);

$retryIn = $limiter->availableIn($key);    // seconds until reset

$resetAt = $limiter->resetAt($key);         // Unix timestamp



// Clear all hits for a key

$limiter->clear($key);

Named Limiters

Define reusable rate limit configurations with named limiters:

use Razy\RateLimit\Limit;



// Register named limiters

$limiter->for('api', function (array $context) {

    $user = $context['user'] ?? null;

    if ($user && $user->isPremium()) {

        return Limit::perMinute(120)->by("api:{$user->id}");

    }

    return Limit::perMinute(30)->by('api:' . ($context['ip'] ?? 'unknown'));

});



$limiter->for('login', function (array $context) {

    return Limit::perMinute(5)->by('login:' . ($context['ip'] ?? ''));

});



$limiter->for('uploads', function (array $context) {

    return Limit::perHour(50)->by("upload:{$context['user']->id}");

});



// Check if a named limiter exists

$limiter->hasLimiter('api');     // true



// Resolve a named limiter to get its Limit config

$limit = $limiter->resolve('api', ['user' => $currentUser, 'ip' => $ip]);

if ($limit && !$limit->isUnlimited()) {

    $allowed = $limiter->attempt(

        $limit->getKey(),

        $limit->getMaxAttempts(),

        $limit->getDecaySeconds()

    );

}

Limit Configuration

The Limit class configures rate limiting parameters using static factory methods:

use Razy\RateLimit\Limit;



// Per-minute (60 seconds decay)

$limit = Limit::perMinute(30);



// Per-hour (3600 seconds)

$limit = Limit::perHour(1000);



// Per-day (86400 seconds)

$limit = Limit::perDay(10000);



// Custom interval

$limit = Limit::every(decaySeconds: 120, maxAttempts: 10);



// Unlimited (no rate limiting)

$limit = Limit::none();



// Set the bucket key

$limit = Limit::perMinute(30)->by('api:user-42');



// Read configuration

$limit->getMaxAttempts();    // 30

$limit->getDecaySeconds();   // 60

$limit->getKey();            // 'api:user-42'

$limit->isUnlimited();      // false

Middleware

The RateLimitMiddleware integrates rate limiting into request pipelines and automatically sets HTTP headers:

use Razy\RateLimit\RateLimitMiddleware;

use Razy\RateLimit\RateLimitExceededException;



$middleware = new RateLimitMiddleware(

    limiter: $limiter,

    name: 'api',                          // named limiter

    keyResolver: function (array $context) {

        return 'api:' . $context['user_id'];

    },

    onLimitExceeded: function (RateLimitExceededException $e) {

        http_response_code(429);

        echo json_encode([

            'error'       => 'Too many requests',

            'retry_after' => $e->getRetryAfter(),

        ]);

    },

    sendHeaders: true                      // send X-RateLimit-* headers

);



// In a pipeline context

$result = $middleware->handle($context, function ($context) {

    return processRequest($context);

});

HTTP Headers

When sendHeaders: true (default), the middleware sets:

| Header | Description | Example |

| --- | --- | --- |

| X-RateLimit-Limit | Max attempts per window | 100 |

| X-RateLimit-Remaining | Remaining attempts | 87 |

| X-RateLimit-Reset | Unix timestamp when window resets | 1700000060 |

| Retry-After | Seconds until retry (only on 429) | 42 |


Storage Backends

ArrayStore (Testing)

In-memory store — data is lost between requests:

use Razy\RateLimit\Store\ArrayStore;



$store = new ArrayStore();

$limiter = new RateLimiter($store);



// Testing helpers

$store->getRecords();        // all stored records

$store->count();             // number of active keys

$store->flush();             // clear all records

CacheStore (Production)

Uses Razy's PSR-16 Cache interface for persistent storage:

use Razy\RateLimit\Store\CacheStore;

use Razy\Cache\RedisAdapter;



$redis = new Redis();

$redis->connect('127.0.0.1');



$store = new CacheStore(

    cache: new RedisAdapter($redis),

    prefix: 'ratelimit_'

);

$limiter = new RateLimiter($store);



// Inspect

$store->getCache();     // the CacheInterface instance

$store->getPrefix();    // 'ratelimit_'

Custom Store

Implement RateLimitStoreInterface for custom backends:

use Razy\Contract\RateLimitStoreInterface;



class DatabaseStore implements RateLimitStoreInterface

{

    public function get(string $key): ?array

    {

        $row = DB::table('rate_limits')->where('key', $key)->first();

        if (!$row) return null;

        return ['hits' => $row->hits, 'resetAt' => $row->reset_at];

    }



    public function set(string $key, int $hits, int $resetAt): void

    {

        DB::table('rate_limits')->upsert(

            ['key' => $key, 'hits' => $hits, 'reset_at' => $resetAt],

            ['key'],

            ['hits', 'reset_at']

        );

    }



    public function delete(string $key): void

    {

        DB::table('rate_limits')->where('key', $key)->delete();

    }

}

Testing

Clock Override

Override the internal clock for deterministic testing:

$store = new ArrayStore();

$store->setCurrentTime(1700000000);



$limiter = new RateLimiter($store);

$limiter->setCurrentTime(1700000000);



// All time-based operations use the override

$limiter->attempt('test', 5, 60);



// Advance time

$store->setCurrentTime(1700000061);  // 61 seconds later

$limiter->setCurrentTime(1700000061);



// Window has expired → attempts reset

$limiter->attempts('test'); // 0

RateLimitExceededException

use Razy\RateLimit\RateLimitExceededException;



try {

    if (!$limiter->attempt($key, 5, 60)) {

        throw new RateLimitExceededException(

            key: $key,

            maxAttempts: 5,

            retryAfter: $limiter->availableIn($key)

        );

    }

} catch (RateLimitExceededException $e) {

    $e->getKey();          // rate limit key

    $e->getMaxAttempts();  // 5

    $e->getRetryAfter();   // seconds to wait

    // HTTP code: 429

}

API Reference

RateLimiter

| Method | Signature | Description |

| --- | --- | --- |

| __construct | (RateLimitStoreInterface $store) | Create with store |

| for | (string $name, Closure $callback): void | Register named limiter |

| limiter | (string $name): ?Closure | Get limiter callback |

| hasLimiter | (string $name): bool | Check limiter exists |

| attempt | (string $key, int $maxAttempts, int $decaySeconds): bool | Check + record |

| tooManyAttempts | (string $key, int $maxAttempts): bool | Read-only check |

| hit | (string $key, int $decaySeconds): int | Record hit, return total |

| remaining | (string $key, int $maxAttempts): int | Remaining attempts |

| availableIn | (string $key): int | Seconds until reset |

| resetAt | (string $key): int | Reset Unix timestamp |

| attempts | (string $key): int | Current hit count |

| clear | (string $key): void | Clear all hits |

| resolve | (string $name, array $context = []): ?Limit | Resolve named limiter |

| getStore | (): RateLimitStoreInterface | Get store instance |

| setCurrentTime | (?int $timestamp): void | Testing clock override |

Limit

| Method | Signature | Description |

| --- | --- | --- |

| perMinute (static) | (int $maxAttempts): static | 60s window |

| perHour (static) | (int $maxAttempts): static | 3600s window |

| perDay (static) | (int $maxAttempts): static | 86400s window |

| every (static) | (int $decaySeconds, int $maxAttempts): static | Custom window |

| none (static) | (): static | Unlimited |

| by | (string $key): static | Set bucket key |

| getMaxAttempts | (): int | Max attempts |

| getDecaySeconds | (): int | Window duration |

| getKey | (): string | Bucket key |

| isUnlimited | (): bool | Is unlimited? |

RateLimitStoreInterface

| Method | Signature | Description |

| --- | --- | --- |

| get | (string $key): ?array | Returns {hits, resetAt} or null |

| set | (string $key, int $hits, int $resetAt): void | Store record |

| delete | (string $key): void | Remove record |

← Previous: AuthManager

Profiler

Clone this wiki locally