Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion Attribute/RateLimit.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,15 @@ public function __construct(
/**
* @var mixed Generic payload
*/
public mixed $payload = null
public mixed $payload = null,

/**
* @var bool|null Defines if the rate limiter blocks the request when a technical problem occurs (default).
* For example, when the Redis database which is used as a rate limit storage is down.
* If set to `false`, the request is allowed to proceed even if the rate limiter cannot determine if the rate limit has been exceeded.
* `null` means that the globally-configured default should be used
*/
public ?bool $failOpen = null
) {
// @RateLimit annotation used to support single method passed as string, keep that for retrocompatibility
if (!is_array($methods)) {
Expand Down Expand Up @@ -75,4 +83,9 @@ public function setPayload(mixed $payload): void
{
$this->payload = $payload;
}

public function setFailOpen(bool $failOpen): void
{
$this->failOpen = $failOpen;
}
}
5 changes: 5 additions & 0 deletions DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ public function getConfigTreeBuilder(): TreeBuilder
->end()
->end()
->end()
->booleanNode('fail_open')
->defaultFalse()
->treatNullLike(false)
->info('Defines if the rate limiter blocks the request when a technical problem occurs')
->end()
->booleanNode('fos_oauth_key_listener')
->defaultTrue()
->info('Enabled the FOS OAuthServerBundle listener')
Expand Down
2 changes: 2 additions & 0 deletions DependencyInjection/NoxlogicRateLimitExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ private function loadServices(ContainerBuilder $container, array $config): void

$container->setParameter('noxlogic_rate_limit.path_limits', $config['path_limits']);

$container->setParameter('noxlogic_rate_limit.fail_open', $config['fail_open']);

switch ($config['storage_engine']) {
case 'memcache':
$container->setParameter('noxlogic_rate_limit.storage.class', 'Noxlogic\RateLimitBundle\Service\Storage\Memcache');
Expand Down
52 changes: 32 additions & 20 deletions EventListener/RateLimitAnnotationListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Noxlogic\RateLimitBundle\Events\GenerateKeyEvent;
use Noxlogic\RateLimitBundle\Events\RateLimitEvents;
use Noxlogic\RateLimitBundle\Exception\RateLimitExceptionInterface;
use Noxlogic\RateLimitBundle\Exception\Storage\RateLimitStorageExceptionInterface;
use Noxlogic\RateLimitBundle\Service\RateLimitService;
use Noxlogic\RateLimitBundle\Util\PathLimitProcessor;
use Symfony\Component\HttpFoundation\Request;
Expand All @@ -33,6 +34,9 @@ public function __construct(
$this->pathLimitProcessor = $pathLimitProcessor;
}

/**
* @throws RateLimitStorageExceptionInterface
*/
public function onKernelController(ControllerEvent $event): void
{
// Skip if the bundle isn't enabled (for instance in test environment)
Expand Down Expand Up @@ -68,31 +72,39 @@ public function onKernelController(ControllerEvent $event): void

$key = $this->getKey($event, $rateLimit, $rateLimits);

// Ratelimit the call
$rateLimitInfo = $this->rateLimitService->limitRate($key);
if (! $rateLimitInfo) {
// Create new rate limit entry for this call
$rateLimitInfo = $this->rateLimitService->createRate($key, $rateLimit->getLimit(), $rateLimit->getPeriod());
$shouldFailOpenOnStorageError = $rateLimit->failOpen ?? $this->getParameter('fail_open', false);
try {
// Ratelimit the call
$rateLimitInfo = $this->rateLimitService->limitRate($key);
if (! $rateLimitInfo) {
// @codeCoverageIgnoreStart
return;
// @codeCoverageIgnoreEnd
// Create new rate limit entry for this call
$rateLimitInfo = $this->rateLimitService->createRate($key, $rateLimit->getLimit(), $rateLimit->getPeriod());
if (! $rateLimitInfo) {
// @codeCoverageIgnoreStart
return;
// @codeCoverageIgnoreEnd
}
}
}


// Store the current rating info in the request attributes
$request->attributes->set('rate_limit_info', $rateLimitInfo);

// Reset the rate limits
if(time() >= $rateLimitInfo->getResetTimestamp()) {
$this->rateLimitService->resetRate($key);
$rateLimitInfo = $this->rateLimitService->createRate($key, $rateLimit->getLimit(), $rateLimit->getPeriod());
if (! $rateLimitInfo) {
// @codeCoverageIgnoreStart
// Store the current rating info in the request attributes
$request->attributes->set('rate_limit_info', $rateLimitInfo);

// Reset the rate limits
if(time() >= $rateLimitInfo->getResetTimestamp()) {
$this->rateLimitService->resetRate($key);
$rateLimitInfo = $this->rateLimitService->createRate($key, $rateLimit->getLimit(), $rateLimit->getPeriod());
if (! $rateLimitInfo) {
// @codeCoverageIgnoreStart
return;
// @codeCoverageIgnoreEnd
}
}
} catch (RateLimitStorageExceptionInterface $storageException) {
if ($shouldFailOpenOnStorageError) {
return;
// @codeCoverageIgnoreEnd
}

throw $storageException;
}

// When we exceeded our limit, return a custom error response
Expand Down
15 changes: 15 additions & 0 deletions Exception/Storage/CreateRateRateLimitStorageException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);

namespace Noxlogic\RateLimitBundle\Exception\Storage;

/**
* @internal BC promise does not cover this class. Do not use directly
*/
final class CreateRateRateLimitStorageException extends RateLimitStorageException
{
public function __construct(\Throwable $previous)
{
parent::__construct('Failed to create rate limit', $previous);
}
}
15 changes: 15 additions & 0 deletions Exception/Storage/GetRateInfoRateLimitStorageException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);

namespace Noxlogic\RateLimitBundle\Exception\Storage;

/**
* @internal BC promise does not cover this class. Do not use directly
*/
final class GetRateInfoRateLimitStorageException extends RateLimitStorageException
{
public function __construct(\Throwable $previous)
{
parent::__construct('Failed to get rate limit info', $previous);
}
}
15 changes: 15 additions & 0 deletions Exception/Storage/LimitRateRateLimitStorageException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);

namespace Noxlogic\RateLimitBundle\Exception\Storage;

/**
* @internal BC promise does not cover this class. Do not use directly
*/
final class LimitRateRateLimitStorageException extends RateLimitStorageException
{
public function __construct(\Throwable $previous)
{
parent::__construct('Failed to apply rate limit', $previous);
}
}
21 changes: 21 additions & 0 deletions Exception/Storage/RateLimitStorageException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);

namespace Noxlogic\RateLimitBundle\Exception\Storage;

/**
* @internal BC promise does not cover this class. Do not use directly
*/
abstract class RateLimitStorageException extends \Exception implements RateLimitStorageExceptionInterface
{
public function __construct(string $problemDescription, \Throwable $previous)
{
$message = \sprintf('Rate limit storage: %s', $problemDescription);

if ($previous->getMessage()) {
$message .= \sprintf(': %s', $previous->getMessage());
}

parent::__construct($message, previous: $previous);
}
}
12 changes: 12 additions & 0 deletions Exception/Storage/RateLimitStorageExceptionInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);

namespace Noxlogic\RateLimitBundle\Exception\Storage;

/**
* A technical problem happened at the storage-level
*/
interface RateLimitStorageExceptionInterface extends \Throwable
{

}
15 changes: 15 additions & 0 deletions Exception/Storage/ResetRateRateLimitStorageException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);

namespace Noxlogic\RateLimitBundle\Exception\Storage;

/**
* @internal BC promise does not cover this class. Do not use directly
*/
final class ResetRateRateLimitStorageException extends RateLimitStorageException
{
public function __construct(\Throwable $previous)
{
parent::__construct('Failed to reset rate', $previous);
}
}
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,17 @@ noxlogic_rate_limit:
# - { path: /api, limit: 1000, period: 3600 }
# - { path: /dashboard, limit: 100, period: 3600, methods: ['GET', 'POST']}

# Defines if the rate limiter blocks the request when a technical problem occurs.
# For example, when the Redis database which is used as a rate limit storage is down.
#
# Possible values:
# false (default): The request is blocked when a storage error occurs
# true: The rate limit is ignored and the request is processed when a storage error occurs
#
# This config is used as a global default.
# It can be overridden for individual routes by setting the \Noxlogic\RateLimitBundle\Attribute\RateLimit::$failOpen attribute
fail_open: false

# Should the FOS OAuthServerBundle listener be enabled
fos_oauth_key_listener: true
```
Expand Down Expand Up @@ -214,6 +225,43 @@ class DefaultController extends Controller
}
```

### Configure fail-open behaviour

You can skip enforcing the rate limit when the rate limit storage is not available.

> If you set `$failOpen` to `true` or `false`, it takes precedence over the globally configured value (`noxlogic_rate_limit.fail_open`).

```php
<?php

use Noxlogic\RateLimitBundle\Attribute\RateLimit;
use Symfony\Component\Routing\Annotation\Route;

#[Route(...)]
#[RateLimit(limit: 1000, period: 3600, failOpen: true)] // The rate limiting is skipped when the storage is not available
public function rateLimitEnforcedIfPossibleAction()
{
}

#[Route(...)]
#[RateLimit(limit: 1000, period: 3600, failOpen: false)] // A rate limit error is returned when the storage is not available
public function rateLimitAlwaysEnforcedAction()
{
}

#[Route(...)]
#[RateLimit(limit: 1000, period: 3600, failOpen: null)] // Will use the fail-open behaviour configured in "noxlogic_rate_limit.fail_open"
public function defaultFailOpenAction()
{
}

#[Route(...)]
#[RateLimit(limit: 1000, period: 3600)] // Will use the fail-open behaviour configured in "noxlogic_rate_limit.fail_open"
public function alsoDefaultFailOpenAction()
{
}
```

## Create a custom key generator

### NOTE
Expand Down
4 changes: 4 additions & 0 deletions Resources/config/services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
<argument>enabled</argument>
<argument>%noxlogic_rate_limit.enabled%</argument>
</call>
<call method="setParameter">
<argument>fail_open</argument>
<argument>%noxlogic_rate_limit.fail_open%</argument>
</call>
<call method="setParameter">
<argument>rate_response_code</argument>
<argument>%noxlogic_rate_limit.rate_response_code%</argument>
Expand Down
7 changes: 4 additions & 3 deletions Service/RateLimitService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Noxlogic\RateLimitBundle\Service;

use Noxlogic\RateLimitBundle\Exception\Storage\RateLimitStorageExceptionInterface;
use Noxlogic\RateLimitBundle\Service\Storage\StorageInterface;

class RateLimitService
Expand Down Expand Up @@ -32,23 +33,23 @@ public function getStorage()
}

/**
*
* @throws RateLimitStorageExceptionInterface
*/
public function limitRate($key)
{
return $this->storage->limitRate($key);
}

/**
*
* @throws RateLimitStorageExceptionInterface
*/
public function createRate($key, $limit, $period)
{
return $this->storage->createRate($key, $limit, $period);
}

/**
*
* @throws RateLimitStorageExceptionInterface
*/
public function resetRate($key)
{
Expand Down
Loading