monkeyscloud/monkeyslegion-rate-limit

Production-grade rate limiting with token bucket and sliding window algorithms, Redis backend, per-route + per-user + per-IP composite buckets, and PSR-15 middleware for MonkeysLegion.

Maintainers

Package info

github.com/MonkeysCloud/MonkeysLegion-Rate-Limit

Homepage

Issues

pkg:composer/monkeyscloud/monkeyslegion-rate-limit

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

1.0.0 2026-05-23 00:18 UTC

This package is auto-updated.

Last update: 2026-05-23 00:22:02 UTC


README

Production-grade rate limiting for MonkeysLegion Framework with token bucket and sliding window algorithms, Redis backend, and PSR-15 middleware.

Features

  • Two algorithms: Token Bucket (burst-friendly) and Sliding Window (precise counting)
  • Redis Lua scripts: Atomic operations — no race conditions at scale
  • Composite key resolvers: Per-IP, per-user, per-route, or any combination (ip+route, user+route)
  • #[RateLimit] attribute: Repeatable, class + method targets, named limiter references
  • PSR-15 middleware: RFC-compliant rate limit headers
  • Resilient storage: Fail-open/fail-closed modes with circuit breaker — no 500 errors when Redis is down
  • MLC configuration: Full config file with env var overrides and named limiter definitions
  • Telemetry integration: Optional metrics for monitoring (counters, gauges)
  • GDPR compliance: Optional IP hashing, trusted proxy support
  • PHP 8.4: Property hooks, backed enums, asymmetric visibility

Installation

composer require monkeyscloud/monkeyslegion-rate-limit

For production use with Redis:

# Ensure ext-redis is installed
pecl install redis

MLC Configuration

Copy the config file to your project's config/ directory:

cp vendor/monkeyscloud/monkeyslegion-rate-limit/config/rate-limit.mlc config/rate-limit.mlc

Full Configuration Reference

rate_limit {
    #
    #--------------------------------------------------------------------------
    # Global Defaults
    #--------------------------------------------------------------------------
    #
    max_attempts   = ${RATE_LIMIT_MAX_ATTEMPTS:-60}
    window_seconds = ${RATE_LIMIT_WINDOW_SECONDS:-60}
    algorithm      = ${RATE_LIMIT_ALGORITHM:-token_bucket}
    by             = ${RATE_LIMIT_BY:-ip}
    cost           = ${RATE_LIMIT_COST:-1}

    #
    #--------------------------------------------------------------------------
    # Storage Backend
    #--------------------------------------------------------------------------
    #
    # Supported drivers: "redis", "memory"
    #
    storage {
        driver = ${RATE_LIMIT_STORAGE:-redis}

        redis {
            host     = ${REDIS_HOST:-127.0.0.1}
            port     = ${REDIS_PORT:-6379}
            username = ${REDIS_USERNAME:-null}
            password = ${REDIS_PASSWORD:-null}
            database = ${REDIS_RATE_LIMIT_DB:-1}
            timeout  = ${REDIS_TIMEOUT:-2.0}
            prefix   = ${RATE_LIMIT_KEY_PREFIX:-ml_rl:}
        }
    }

    #
    #--------------------------------------------------------------------------
    # Resilience — Fail-Open / Circuit Breaker
    #--------------------------------------------------------------------------
    #
    resilience {
        enabled                  = ${RATE_LIMIT_RESILIENCE:-true}
        fail_open                = ${RATE_LIMIT_FAIL_OPEN:-true}
        fallback_driver          = ${RATE_LIMIT_FALLBACK:-memory}
        circuit_threshold        = ${RATE_LIMIT_CIRCUIT_THRESHOLD:-3}
        circuit_recovery_seconds = ${RATE_LIMIT_CIRCUIT_RECOVERY:-30}
    }

    #
    #--------------------------------------------------------------------------
    # IP Resolution
    #--------------------------------------------------------------------------
    #
    ip {
        trusted_proxies = ${RATE_LIMIT_TRUSTED_PROXIES:-null}
        hash_ips        = ${RATE_LIMIT_HASH_IPS:-false}
    }

    #
    #--------------------------------------------------------------------------
    # Named Limiters
    #--------------------------------------------------------------------------
    #
    limiters {
        api {
            max_attempts   = ${RATE_LIMIT_API_MAX:-100}
            window_seconds = ${RATE_LIMIT_API_WINDOW:-60}
            algorithm      = token_bucket
            by             = ip
        }

        auth {
            max_attempts   = ${RATE_LIMIT_AUTH_MAX:-5}
            window_seconds = ${RATE_LIMIT_AUTH_WINDOW:-300}
            algorithm      = sliding_window
            by             = ip
        }

        uploads {
            max_attempts   = ${RATE_LIMIT_UPLOADS_MAX:-10}
            window_seconds = ${RATE_LIMIT_UPLOADS_WINDOW:-3600}
            algorithm      = token_bucket
            by             = user+route
            cost           = 5
        }
    }

    #
    #--------------------------------------------------------------------------
    # Telemetry
    #--------------------------------------------------------------------------
    #
    telemetry {
        enabled = ${RATE_LIMIT_TELEMETRY:-true}
    }

    #
    #--------------------------------------------------------------------------
    # Response Customization
    #--------------------------------------------------------------------------
    #
    response {
        message         = ${RATE_LIMIT_RESPONSE_MESSAGE:-Too many requests. Please try again later.}
        include_headers = ${RATE_LIMIT_INCLUDE_HEADERS:-true}
    }
}

Environment Variable Quick Reference

Variable Default Description
RATE_LIMIT_STORAGE redis Storage driver: redis or memory
RATE_LIMIT_MAX_ATTEMPTS 60 Default requests per window
RATE_LIMIT_WINDOW_SECONDS 60 Default window in seconds
RATE_LIMIT_ALGORITHM token_bucket Default algorithm
RATE_LIMIT_BY ip Default key strategy
RATE_LIMIT_FAIL_OPEN true Allow traffic when Redis is down
RATE_LIMIT_CIRCUIT_THRESHOLD 3 Failures before circuit opens
RATE_LIMIT_CIRCUIT_RECOVERY 30 Seconds before retrying Redis
REDIS_HOST 127.0.0.1 Redis server host
REDIS_PORT 6379 Redis server port
REDIS_RATE_LIMIT_DB 1 Redis database for rate limit keys
RATE_LIMIT_HASH_IPS false Hash IPs for GDPR compliance
RATE_LIMIT_TELEMETRY true Enable telemetry counters

Bootstrapping with the Provider

Automatic (Recommended)

The RateLimitProvider wires everything from MLC config in a single call:

use MonkeysLegion\Mlc\Loader;
use MonkeysLegion\RateLimit\Provider\RateLimitProvider;

// 1. Load the MLC config
$config = $loader->loadOne('rate-limit');

// 2. Bootstrap the entire rate limit stack
$services = RateLimitProvider::register(
    config: $config->get('rate_limit', []),
    logger: $logger,                        // PSR-3 logger
);

// 3. Register the middleware in the router
$router->registerMiddleware(
    'rate-limit',
    $services['middleware'],
    priority: 100,
);
$router->addGlobalMiddleware('rate-limit');

The Provider creates:

  • $services['storage'] — The storage backend (Redis → ResilientStorage wrapper)
  • $services['manager']RateLimiterManager with named limiters from config
  • $services['middleware']RateLimitMiddleware ready for the router

DI Container Registration

// In your container definitions (e.g., di/definitions.php)
use MonkeysLegion\RateLimit\Provider\RateLimitProvider;
use MonkeysLegion\RateLimit\RateLimiterManager;
use MonkeysLegion\RateLimit\Middleware\RateLimitMiddleware;

return [
    // Load config
    'rate_limit.config' => fn(Config $config) => $config->get('rate_limit', []),

    // Bootstrap services
    'rate_limit.services' => fn(array $config, LoggerInterface $logger) =>
        RateLimitProvider::register(config: $config, logger: $logger),

    // Individual service bindings
    RateLimiterManager::class  => fn(array $services) => $services['manager'],
    RateLimitMiddleware::class => fn(array $services) => $services['middleware'],
];

With Pre-existing Redis Connection

If your app already has a shared Redis connection (e.g., from the queue or cache package):

$services = RateLimitProvider::register(
    config: $config->get('rate_limit', []),
    logger: $logger,
    redis:  $existingRedis,  // Reuse connection — config host/port are ignored
);

Manual Bootstrap (Without Provider)

use MonkeysLegion\RateLimit\RateLimiterManager;
use MonkeysLegion\RateLimit\Middleware\RateLimitMiddleware;
use MonkeysLegion\RateLimit\Storage\RedisStorage;
use MonkeysLegion\RateLimit\Storage\ResilientStorage;
use MonkeysLegion\RateLimit\Storage\InMemoryStorage;
use MonkeysLegion\RateLimit\RateLimiter;

// 1. Create Redis connection
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
$redis->select(1);

// 2. Wrap with resilience
$storage = new ResilientStorage(
    primary:  new RedisStorage($redis, prefix: 'ml_rl:'),
    failOpen: true,
    fallback: new InMemoryStorage(),
    logger:   $logger,
);

// 3. Create manager and register named limiters
$manager = new RateLimiterManager($storage);

$manager->define('api', fn() => [
    'maxAttempts'   => 100,
    'windowSeconds' => 60,
    'by'            => 'ip',
    'algorithm'     => 'token_bucket',
]);

$manager->define('auth', fn() => [
    'maxAttempts'   => 5,
    'windowSeconds' => 300,
    'by'            => 'ip',
    'algorithm'     => 'sliding_window',
]);

// 4. Create middleware and register
$middleware = new RateLimitMiddleware($manager);
$router->registerMiddleware('rate-limit', $middleware, priority: 100);
$router->addGlobalMiddleware('rate-limit');

// 5. Initialize facade for programmatic use
RateLimiter::init($manager);

Quick Start — Using Rate Limits

Attribute-Based (Recommended)

use MonkeysLegion\RateLimit\Attributes\RateLimit;
use MonkeysLegion\RateLimit\Algorithm;

#[RoutePrefix('/api/v2/users')]
final class UserController
{
    // 60 requests per minute, per IP (token bucket)
    #[Route('GET', '/', name: 'users.index')]
    #[RateLimit(maxAttempts: 60, windowSeconds: 60)]
    public function index(): Response { /* ... */ }

    // 5 login attempts per 5 minutes, per IP (sliding window)
    #[Route('POST', '/login', name: 'users.login')]
    #[RateLimit(maxAttempts: 5, windowSeconds: 300, algorithm: Algorithm::SlidingWindow)]
    public function login(): Response { /* ... */ }

    // Stacked: global IP limit + per-user per-route limit
    #[Route('POST', '/', name: 'users.create')]
    #[RateLimit(maxAttempts: 1000, windowSeconds: 3600, by: 'ip')]
    #[RateLimit(maxAttempts: 10, windowSeconds: 60, by: 'user+route')]
    public function create(): Response { /* ... */ }

    // Reference a named limiter from config
    #[Route('GET', '/search', name: 'users.search')]
    #[RateLimit(limiter: 'api')]
    public function search(): Response { /* ... */ }
}

Programmatic Usage (Facade)

use MonkeysLegion\RateLimit\RateLimiter;

// After bootstrap (RateLimiter::init() was called by the Provider)

$result = RateLimiter::attempt('login:192.168.1.1', maxAttempts: 5, windowSeconds: 300);

if ($result->exceeded) {
    echo "Try again in {$result->retryAfter} seconds";
}

// Check without consuming
if (RateLimiter::tooManyAttempts('api:10.0.0.1', 100, 60)) {
    // Already at limit
}

// Clear all state for a key (e.g., after password reset)
RateLimiter::clear('login:192.168.1.1');

From Attribute (Standalone)

Use fromAttribute() to evaluate rate limits from a #[RateLimit] attribute without the middleware:

use MonkeysLegion\RateLimit\Attributes\RateLimit;
use MonkeysLegion\RateLimit\Algorithm;

$attr    = new RateLimit(maxAttempts: 100, windowSeconds: 60, by: 'ip+route');
$result  = $manager->fromAttribute($attr, $request);

if (!$result->allowed) {
    // Handle rate limit exceeded
}

// Works with named limiters too
$attr   = new RateLimit(limiter: 'api');
$result = $manager->fromAttribute($attr, $request);

Redis Unavailability — Resilient Storage

Neither Laravel nor Symfony handle this. Both throw 500 errors when Redis goes down. MonkeysLegion is the first PHP framework to ship a built-in resilient rate limiter.

The Problem

Framework When Redis is Down
Laravel ThrottleRequests throws ConnectionException500 error
Symfony Rate Limiter throws exception → 500 error
MonkeysLegion Configurable: fail-open, fail-closed, or fallback — your API keeps working

Three Modes of Operation

Mode MLC Config Behavior When Redis Is Down
Fail-Open fail_open = true All requests allowed — no rate limiting
Fail-Open + Fallback fail_open = true, fallback_driver = memory Per-process rate limiting (no cross-worker consistency)
Fail-Closed fail_open = false All requests denied with 429 — maximum security

Circuit Breaker Pattern

CLOSED (normal) → 3 consecutive failures → OPEN (skip Redis)
                                              ↓
                               30 seconds elapse
                                              ↓
                                    HALF-OPEN (probe Redis)
                                              ↓
                              Success → CLOSED  |  Failure → OPEN

Development Without Redis

# .env
RATE_LIMIT_STORAGE=memory

Or in the MLC file:

storage {
    driver = memory
}

Note: memory driver is single-process. Rate limits are not shared across workers.

Router Integration

The rate limiter hooks into the router via the #[RateLimit] attribute system:

Controller → ControllerScanner → Route Meta → Router Dispatch → Request Attribute → Middleware

How It Works

  1. ControllerScanner scans for #[RateLimit] attributes on controller classes and methods
  2. Rate limit configs are stored in meta['rate_limits'] on each RouteDefinition
  3. Router::dispatch() attaches _rate_limits as a PSR-7 request attribute
  4. RateLimitMiddleware reads _rate_limits, resolves keys, evaluates limits, returns 429 or adds headers

Backwards Compatibility

The existing #[Throttle] attribute continues to work — the scanner bridges it automatically:

// Old way — still works
#[Throttle(max: 60, per: 60, by: 'ip')]

// New way — more features
#[RateLimit(maxAttempts: 60, windowSeconds: 60, by: 'ip', algorithm: Algorithm::TokenBucket)]

Competitive Comparison

Feature MonkeysLegion Laravel 13 Symfony 7
Token Bucket ❌ (fixed window)
Sliding Window ❌ (fixed window)
Redis Lua Atomic ✅ (Redis driver) ❌ (uses locks)
Fail-Open Mode ❌ (500 error) ❌ (500 error)
Circuit Breaker
Fallback Storage
MLC Config YAML/PHP YAML
Composite Keys ✅ (ip+route) ❌ (manual)
Stacked Limits ✅ (repeatable attr)
PHP 8.4 Hooks
PSR-15 Standard ❌ (custom) ❌ (custom)
Named Limiters
IP Hashing (GDPR)

Algorithms

Token Bucket

Best for APIs that allow burst traffic while maintaining a steady average rate.

  • Capacity = maxAttempts (burst size)
  • Refill rate = maxAttempts / windowSeconds tokens per second
  • Each request consumes cost tokens (default: 1)
  • Tokens refill continuously based on elapsed time

Sliding Window

Best for strict rate limiting with precise request counting.

  • Tracks each request timestamp in a rolling window
  • No burst allowance — exactly maxAttempts per windowSeconds
  • Uses Redis sorted sets for O(log n) operations

Key Resolvers

Resolver Description
ip Client IP (with trusted proxy support)
user Authenticated user ID (falls back to IP)
route Route pattern (e.g., /api/users/{id})
ip+route Per-IP per-route composite
user+route Per-user per-route composite

Response Headers

Follows RFC 6585 + draft-ietf-httpapi-ratelimit-headers.

On successful requests:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1716350400
X-RateLimit-Policy: 60;w=60

On rate-limited requests (429):

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1716350400
X-RateLimit-Policy: 60;w=60
Retry-After: 30
Header Description
X-RateLimit-Limit Maximum requests allowed in the window
X-RateLimit-Remaining Requests remaining in the current window
X-RateLimit-Reset Unix timestamp when the window resets
X-RateLimit-Policy Limit and window in {limit};w={seconds} format (draft spec)
Retry-After Seconds until next request is allowed (only on 429)

Telemetry Metrics

When telemetry is enabled, the middleware emits the following metrics via the callback:

Metric Type Labels
ml_rate_limit_allowed_total Counter resolver, algorithm
ml_rate_limit_denied_total Counter resolver, algorithm
ml_rate_limit_remaining Gauge resolver, algorithm, remaining, limit
// Custom telemetry callback example
$middleware = new RateLimitMiddleware($manager, metricsCallback: function (
    string $metricName,
    array $labels,
): void {
    // Emit to Prometheus, StatsD, or any monitoring backend
    Telemetry::counter($metricName, $labels);
});

Requirements

  • PHP ^8.4
  • ext-redis (recommended for production)
  • PSR-7 HTTP Message ^2.0
  • PSR-15 HTTP Server Middleware ^1.0

License

MIT License — see LICENSE for details.