lyrasoft / throttle
LYRASOFT throttle package
Installs: 5
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
Type:windwalker-package
pkg:composer/lyrasoft/throttle
Requires
- php: >=8.4.6
- symfony/lock: ^6.0||^7.0||^8.0||^9.0
- symfony/rate-limiter: ^6.0||^7.0||^8.0||^9.0
- windwalker/core: ^4.2
README
This package implement throttling for Windwalker, using symfony RateLimiter and Lock packages.
Installation
Install from composer
composer require lyrasoft/throttle
Then copy files to project
php windwalker pkg:install lyrasoft/throttle -t migrations
Lock
Please see Symfony Lock documentation to learn basic usage.
Get Lock Factory and create lock
$lockFactory = $container->get(\Symfony\Component\Lock\LockFactory::class); $lockFactory = $container->get(\Symfony\Component\Lock\LockFactory::class, tag: '...'); $lock = $lockFactory->createLock('user.' . $user->id . '.process', 30); // 30 seconds Timeout $lock->acquire(true); // Wait until acquired
Manually Create
If you want to manually create Lock, you can use ThrottleService.
$throttleService = $app->retrieve(\Lyrasoft\Throttle\Factory\ThrottleService::class); $throttleService->createLockFactory(); // Create Lock and acquire $lock = $throttleService->createLock('user.' . $user->id . '.process', 30); // 30 seconds Timeout $acquired = $lock->acquire(true); // Wait until acquired if ($acquired) { // Acquired lock, do your process here... $lock->release(); // Release lock after process } else { // Failed to acquire lock }
// Instant lock acquire, if acquire success, return Lock object, or null $lock = $throttleService->lock('user.' . $user->id . '.process', 30); if ($lock) { // Acquired lock, do your process here... $lock->release(); // Release lock after process } else { // All locks are acquired, wait or skip process }
Concurrent Locking
If you want to limit concurrent processes, you can use concurrent() method.
Service will auto append 1 to 5 to your ID to get available locks.
$throttleService = $app->retrieve(\Lyrasoft\Throttle\Factory\ThrottleService::class); $lock = $throttleService->concurrent('user.concurrent.' . $user->id, 5); if ($lock) { // Acquired lock, do your process here... $lock->release(); // Release lock after process } else { // All locks are acquired, wait or skip process }
RateLimiter
Please see Symfony RateLimiter documentation to learn basic usage.
Get RateLimiter Factpory and create limiter
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; $factory = $container->get(RateLimiterFactoryInterface::class); $factory = $container->get(RateLimiterFactoryInterface::class, tag: '...'); // Symfony RateLimiter Object $limiter = $factory->create('call.limit.' . $user->id); $limit = $limiter->consume(1); // Consume 1 token $limit->isAccepted(); // BOOL: Check if allowed $limit->getRemainingTokens(); // Get remaining tokens $limit->getRetryAfter(); // Get retry after DateTime $limit->ensureAccepted(); // Throw exception if not accepted
You can configure limiters in etc/throttle.config.php file, for example, this config
set 10 requests per minute limit for default limiter.
'factories' => [ // ... RateLimiterFactoryInterface::class => [ 'default' => fn () => RateLimiterServiceFactory::factory( id: 'default', policy: RateLimitPolicy::FIXED_WINDOW, limit: 10, interval: '1 minute', storage: 'default', locker: true, ), // Create new limiter ID if you need.... ], // ...
Support Policy:
RateLimitPolicy::FIXED_WINDOWRateLimitPolicy::SLIDING_WINDOWRateLimitPolicy::TOKEN_BUCKETRateLimitPolicy::NO_LIMIT
See https://symfony.com/doc/current/rate_limiter.html#fixed-window-rate-limiter
Compound Limiters
If you want ot use compound limiters, you can define multiple limiters in the config.
'factories' => [ // ... RateLimiterFactoryInterface::class => [ 'compound' => fn () => RateLimiterServiceFactory::compoundFactrory( [ 'limiter1', 'limiter2', 'limiter3', ], ), 'limiter1' => ..., 'limiter2' => ..., 'limiter3' => ..., ], // ...
Manually Create
If you want to manually create RateLimiter, you can use ThrottleService.
$throttleService = $app->retrieve(\Lyrasoft\Throttle\Factory\ThrottleService::class); $factory = $throttleService->createRateLimiterFactory( 'user.' . $user->id, \Lyrasoft\Throttle\Enum\RateLimitPolicy::FIXED_WINDOW, 5, '10 minutes', true, ); $limiter = $factory->create('search.action'); $limiter->consume(1); // Consume 1 token
Or instant create limiter
$throttleService = $app->retrieve(\Lyrasoft\Throttle\Factory\ThrottleService::class); $limiter = $throttleService->createRateLimiter( 'user.' . $user->id . '::search.action', // Use :: to separate factory ID and limiter ID \Lyrasoft\Throttle\Enum\RateLimitPolicy::FIXED_WINDOW, 5, '10 minutes', true, ); $limiter = $throttleService->createRateLimiter( 'search.action', // Onlt factory ID, use default limiter ID \Lyrasoft\Throttle\Enum\RateLimitPolicy::FIXED_WINDOW, 5, '10 minutes', true, );
RateLimitMiddleware
Add RateLimitMiddleware to route that can be throttled, by default, this middleware use IP as limiter key.
use Lyrasoft\Throttle\Middleware\RateLimitMiddleware; $router->middleware( RateLimitMiddleware::class, factory: 'default', // Limiter Factory ID ) // ...
If reach limit, middleware will throw 429 Too Many Requests Exception.
Custom Factory
$router->middleware( RateLimitMiddleware::class, factory: fn (Container $container) => new RateLimiterFactory(...), )
Custom Limiter
Use static Key:
$router->middleware( RateLimitMiddleware::class, factory: 'default', limiterKey: 'custom.limiter.key', // Limiter ID in the factory )
Use Callback:
$router->middleware( RateLimitMiddleware::class, factory: 'default', limiterKey: function (\Lyrasoft\Luna\User\UserService $userService, \Windwalker\Core\Http\AppRequest $appRequest) { $user = $userService->getUser(); if ($user->isLogin()) { return 'user.limiter:' . $user->id; } return 'guest.limiter:' . $appRequest->getClientIP(); } )
Custom Consume Count
Static number:
$router->middleware( RateLimitMiddleware::class, factory: 'default', consume: 5, // Consume 5 tokens per request )
Use callback:
$router->middleware( RateLimitMiddleware::class, factory: 'default', consume: function (LimiterInterface, $rateLimiter, string $key, /* Inject */) { if ($key ==== 'vip') { return $rateLimiter->consume(1); } return $rateLimiter->consume(10); }, )
Custom Exceeded Handler and Response
By default, middleware will throw 429 Exception when limit exceeded.
If you want to override this, you can use exceededHandler argument.
$router->middleware( RateLimitMiddleware::class, factory: 'default', exceededHandler: function (/* Inject */) { // You can throw Exception throw new \RuntimeException('Too many requests, please try again later.', 429); // Or return custom Response return new \Windwalker\Http\Response\JsonResponse([ 'message' => 'Too many requests, please try again later.', ], 429); }, )
If exceededHandler returns a Response object, you can configure the headers by infoHeaders argument.
Set infoHeaders to TRUE, RateLimitMiddleware will auto inject headers:
X-RateLimit-Limit: ...
X-RateLimit-Remaining: ...
X-RateLimit-Reset: ...
Set infoHeaders to callback, you can customize response:
$router->middleware( RateLimitMiddleware::class, factory: 'default', exceededHandler: function (/* Inject */) { return new \Windwalker\Http\Response\JsonResponse([ 'message' => 'Too many requests, please try again later.', ], 429); }, infoHeaders: function (ResponseInterface $response, \Symfony\Component\RateLimiter\RateLimit $limit, /* Inject */) { $response = $response->withAddedHeader('X-Custom-RateLimit-Limit', $limiter->getLimit()); $response = $response->withAddedHeader('X-Custom-RateLimit-Remaining', $limiter->getRemainingTokens()); $response = $response->withAddedHeader('X-Custom-RateLimit-Reset', $limiter->getRetryAfter()->getTimestamp()); return $response; }, )