This package implement throttling for Windwalker, using symfony RateLimiter and Lock packages.
Install from composer
composer require lyrasoft/throttleThen copy files to project
php windwalker pkg:install lyrasoft/throttle -t migrationsGet Lock Factory and create lock, you can modify etc/packages/throttle.config.php to configure your lock services.
$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 acquiredPlease see Symfony Lock documentation to learn basic usage.
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
}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 and auto acquire.
$throttleService = $app->retrieve(\Lyrasoft\Throttle\Factory\ThrottleService::class);
$acquired = $throttleService->concurrent('user.concurrent.' . $user->id, 5);
if ($locked) {
[$lock, $key, $serial] = $acquired;
// Acquired lock, do your process here...
$lock->release(); // Release lock after process
} else {
// All locks are acquired, wait or skip process
}If you want to acquire a lock and store it then stop current process, pass this lock to queue or other process, so that we can run a long task later, you can acquire lock first and serialize the key. (Note: Lock object itself is not serializable)
use Symfony\Component\Lock\Key;
$throttleService = $app->retrieve(\Lyrasoft\Throttle\Factory\ThrottleService::class);
$key = new Key('user.lock.' . $user->id);
$lock = $throttleService->createLockFromKey($key, 30, autoRelease: false);
// Start to acquire lock but do not process long task here...
$lock->acquire();
// Then we try to run task in another process, let's store the key.
// Push to queue
$queue->push(new FooJob($key));
// Or serialize then store to DB
$item->lockKey = serialize($key);
$orm->updateOne($item);When you want to run the long task, you can unserialize the key and re-create a lock.
$key = unserialize($item->lockKey);
$lock = $throttleService->createLockFromKey($key, 30);
// Run task....
$lock->release(); // Release lock after processIf you are running concurrent locks, the key will return with the lock in an array.
$locked = $throttleService->concurrent('user.concurrent.' . $user->id, 5);
if ($locked) {
[$lock, $key, $serial] = $locked;
serialize($key);
// Store the key...
} else {
// All locks are acquired, wait or skip process
}Get RateLimiter Factory and create limiter, you can modify etc/packages/throttle.config.php to configure your
RateKLimiter services.
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 acceptedPlease see Symfony RateLimiter documentation to learn basic usage.
You can configure limiters in etc/packages/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
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' => ...,
],
// ...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 tokenOr 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', // Only factory ID, use default limiter ID
\Lyrasoft\Throttle\Enum\RateLimitPolicy::FIXED_WINDOW,
5,
'10 minutes',
true,
);TokenBucket Limiter has a different configuration:
maxTokens: The maximum number of tokens in the bucket.rate: - TheRaterepresents the token refill speed, composed ofrefillTimeandrefillAmount. You can define it using a\Symfony\Component\RateLimiter\Rateobject, or use the string format1hour-5to indicate refilling 5 tokens per hour.
When creating a Token Bucket Limiter, you need to set limit as maxTokens and interval as rate.
$throttleService = $app->retrieve(\Lyrasoft\Throttle\Factory\ThrottleService::class);
$maxTokens = 500; // Start with 500 tokens
// Using helper function
$rate = \Lyrasoft\Throttle\rate(interval: '1minutes', amount: 5); // refill 5 tokens per 1 minute
// Or directly create Rate object
$rate = new \Symfony\Component\RateLimiter\Rate(
refillTime: \DateInterval::createFromDateString('1 minutes'),
refillAmount: 5,
);
// Or string format
$rate = '1hour-5'; // refill 5 tokens per hour
$limiter = $throttleService->createRateLimiter(
id: 'user.' . $user->id . '::video.stream',
policy: \Lyrasoft\Throttle\Enum\RateLimitPolicy::TOKEN_BUCKET,
limit: $maxTokens,
interval: $rate,
);
// Shorthand
$rate = \Symfony\Component\RateLimiter\Policy\Rate::perSecond(5);
$rate = \Symfony\Component\RateLimiter\Policy\Rate::perMinute(5);
$rate = \Symfony\Component\RateLimiter\Policy\Rate::perHour(100);
$rate = \Symfony\Component\RateLimiter\Policy\Rate::perDay(1000);
$rate = \Symfony\Component\RateLimiter\Policy\Rate::perMonth(1000);
$rate = \Symfony\Component\RateLimiter\Policy\Rate::perYear(10000);
$rate = \Symfony\Component\RateLimiter\Policy\Rate::fromString('1hour-5'); // refill 5 tokens per hourAdd 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.
$router->middleware(
RateLimitMiddleware::class,
factory: fn (Container $container) => new RateLimiterFactory(...),
)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();
}
)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);
},
)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;
},
)