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
171 changes: 163 additions & 8 deletions src/Illuminate/Cache/RateLimiter.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,20 +100,21 @@ public function limiter($name)
* @param int $maxAttempts
* @param \Closure $callback
* @param \DateTimeInterface|\DateInterval|int $decaySeconds
* @param bool $slidingWindow
* @return mixed
*/
public function attempt($key, $maxAttempts, Closure $callback, $decaySeconds = 60)
public function attempt($key, $maxAttempts, Closure $callback, $decaySeconds = 60, $slidingWindow = false)
{
if ($this->tooManyAttempts($key, $maxAttempts)) {
if ($this->tooManyAttempts($key, $maxAttempts, $decaySeconds, $slidingWindow)) {
return false;
}

if (is_null($result = $callback())) {
$result = true;
}

return tap($result, function () use ($key, $decaySeconds) {
$this->hit($key, $decaySeconds);
return tap($result, function () use ($key, $decaySeconds, $slidingWindow) {
$this->hit($key, $decaySeconds, $slidingWindow);
});
}

Expand All @@ -122,10 +123,16 @@ public function attempt($key, $maxAttempts, Closure $callback, $decaySeconds = 6
*
* @param string $key
* @param int $maxAttempts
* @param \DateTimeInterface|\DateInterval|int $decaySeconds
* @param bool $slidingWindow
* @return bool
*/
public function tooManyAttempts($key, $maxAttempts)
public function tooManyAttempts($key, $maxAttempts, $decaySeconds = 60, $slidingWindow = false)
{
if ($slidingWindow) {
return $this->slidingWindowTooManyAttempts($key, $maxAttempts, $decaySeconds);
}

if ($this->attempts($key) >= $maxAttempts) {
if ($this->cache->has($this->cleanRateLimiterKey($key).':timer')) {
return true;
Expand All @@ -142,10 +149,15 @@ public function tooManyAttempts($key, $maxAttempts)
*
* @param string $key
* @param \DateTimeInterface|\DateInterval|int $decaySeconds
* @param bool $slidingWindow
* @return int
*/
public function hit($key, $decaySeconds = 60)
public function hit($key, $decaySeconds = 60, $slidingWindow = false)
{
if ($slidingWindow) {
return $this->slidingWindowHit($key, $decaySeconds);
}

return $this->increment($key, $decaySeconds);
}

Expand Down Expand Up @@ -224,10 +236,16 @@ public function resetAttempts($key)
*
* @param string $key
* @param int $maxAttempts
* @param \DateTimeInterface|\DateInterval|int $decaySeconds
* @param bool $slidingWindow
* @return int
*/
public function remaining($key, $maxAttempts)
public function remaining($key, $maxAttempts, $decaySeconds = 60, $slidingWindow = false)
{
if ($slidingWindow) {
return $this->slidingWindowRemaining($key, $maxAttempts, $decaySeconds);
}

$key = $this->cleanRateLimiterKey($key);

$attempts = $this->attempts($key);
Expand All @@ -247,6 +265,134 @@ public function retriesLeft($key, $maxAttempts)
return $this->remaining($key, $maxAttempts);
}

/**
* Increment the counter for a given key using the sliding window algorithm.
*
* @param string $key
* @param \DateTimeInterface|\DateInterval|int $decaySeconds
* @param int $amount
* @return int
*/
protected function slidingWindowHit($key, $decaySeconds = 60, $amount = 1)
{
$key = $this->cleanRateLimiterKey($key);
$decaySeconds = $this->secondsUntil($decaySeconds);

$windowStart = $this->cache->get($key.':sw:timer');
$now = $this->currentTime();

if (is_null($windowStart) || $now >= ($windowStart + $decaySeconds)) {
$previousCount = is_null($windowStart) ? 0 : (int) $this->withoutSerializationOrCompression(
fn () => $this->cache->get($key.':sw:current', 0)
);

$this->withoutSerializationOrCompression(
fn () => $this->cache->put($key.':sw:previous', $previousCount, $decaySeconds * 2)
);
$this->cache->put($key.':sw:timer', $now, $decaySeconds * 2);

$this->withoutSerializationOrCompression(
fn () => $this->cache->put($key.':sw:current', 0, $decaySeconds * 2)
);
}

return (int) $this->withoutSerializationOrCompression(
fn () => $this->cache->increment($key.':sw:current', $amount)
);
}

/**
* Determine if the given key has been "accessed" too many times using the sliding window algorithm.
*
* @param string $key
* @param int $maxAttempts
* @param \DateTimeInterface|\DateInterval|int $decaySeconds
* @return bool
*/
protected function slidingWindowTooManyAttempts($key, $maxAttempts, $decaySeconds = 60)
{
return $this->slidingWindowEffectiveAttempts($key, $decaySeconds) >= $maxAttempts;
}

/**
* Get the number of retries left for the given key using the sliding window algorithm.
*
* @param string $key
* @param int $maxAttempts
* @param \DateTimeInterface|\DateInterval|int $decaySeconds
* @return int
*/
protected function slidingWindowRemaining($key, $maxAttempts, $decaySeconds = 60)
{
return max(0, $maxAttempts - $this->slidingWindowEffectiveAttempts($key, $decaySeconds));
}

/**
* Get the number of seconds until the sliding window resets for the given key.
*
* @param string $key
* @param \DateTimeInterface|\DateInterval|int $decaySeconds
* @return int
*/
protected function slidingWindowAvailableIn($key, $decaySeconds = 60)
{
$key = $this->cleanRateLimiterKey($key);
$decaySeconds = $this->secondsUntil($decaySeconds);

$windowStart = $this->cache->get($key.':sw:timer');

if (is_null($windowStart)) {
return 0;
}

return max(0, ($windowStart + $decaySeconds) - $this->currentTime());
}

/**
* Get the effective number of attempts for the given key using the sliding window algorithm.
*
* @param string $key
* @param \DateTimeInterface|\DateInterval|int $decaySeconds
* @return int
*/
protected function slidingWindowEffectiveAttempts($key, $decaySeconds = 60)
{
$key = $this->cleanRateLimiterKey($key);
$decaySeconds = $this->secondsUntil($decaySeconds);

$windowStart = $this->cache->get($key.':sw:timer');

if (is_null($windowStart)) {
return 0;
}

$now = $this->currentTime();

if ($now >= $windowStart + ($decaySeconds * 2)) {
return 0;
}

$current = (int) $this->withoutSerializationOrCompression(
fn () => $this->cache->get($key.':sw:current', 0)
);

$previous = (int) $this->withoutSerializationOrCompression(
fn () => $this->cache->get($key.':sw:previous', 0)
);

if ($now >= $windowStart + $decaySeconds) {
$elapsed = $now - ($windowStart + $decaySeconds);
$overlapRatio = max(0, 1 - ($elapsed / $decaySeconds));

return (int) floor($overlapRatio * $current);
}

$elapsed = $now - $windowStart;
$overlapRatio = max(0, 1 - ($elapsed / $decaySeconds));

return (int) floor($overlapRatio * $previous) + $current;
}

/**
* Clear the hits and lockout timer for the given key.
*
Expand All @@ -260,16 +406,25 @@ public function clear($key)
$this->resetAttempts($key);

$this->cache->forget($key.':timer');
$this->cache->forget($key.':sw:current');
$this->cache->forget($key.':sw:previous');
$this->cache->forget($key.':sw:timer');
}

/**
* Get the number of seconds until the "key" is accessible again.
*
* @param string $key
* @param \DateTimeInterface|\DateInterval|int $decaySeconds
* @param bool $slidingWindow
* @return int
*/
public function availableIn($key)
public function availableIn($key, $decaySeconds = 60, $slidingWindow = false)
{
if ($slidingWindow) {
return $this->slidingWindowAvailableIn($key, $decaySeconds);
}

$key = $this->cleanRateLimiterKey($key);

return max(0, $this->cache->get($key.':timer') - $this->currentTime());
Expand Down
19 changes: 19 additions & 0 deletions src/Illuminate/Cache/RateLimiting/Limit.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ class Limit
*/
public $decaySeconds;

/**
* Indicates if the sliding window algorithm should be used.
*
* @var bool
*/
public $slidingWindow = false;

/**
* The after callback used to determine if the limiter should be hit.
*
Expand Down Expand Up @@ -136,6 +143,18 @@ public function by($key)
return $this;
}

/**
* Set the rate limiter to use the sliding window algorithm.
*
* @return $this
*/
public function slidingWindow()
{
$this->slidingWindow = true;

return $this;
}

/**
* Set the callback to determine if the limiter should be hit.
*
Expand Down
13 changes: 8 additions & 5 deletions src/Illuminate/Queue/Middleware/RateLimited.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ public function handle($job, $next)
'key' => md5($this->limiterName.$limit->key),
'maxAttempts' => $limit->maxAttempts,
'decaySeconds' => $limit->decaySeconds,
'slidingWindow' => $limit->slidingWindow,
];
})->all()
);
Expand All @@ -94,13 +95,13 @@ public function handle($job, $next)
protected function handleJob($job, $next, array $limits)
{
foreach ($limits as $limit) {
if ($this->limiter->tooManyAttempts($limit->key, $limit->maxAttempts)) {
if ($this->limiter->tooManyAttempts($limit->key, $limit->maxAttempts, $limit->decaySeconds, $limit->slidingWindow)) {
return $this->shouldRelease
? $job->release($this->releaseAfter ?: $this->getTimeUntilNextRetry($limit->key))
? $job->release($this->releaseAfter ?: $this->getTimeUntilNextRetry($limit->key, $limit->decaySeconds, $limit->slidingWindow))
: false;
}

$this->limiter->hit($limit->key, $limit->decaySeconds);
$this->limiter->hit($limit->key, $limit->decaySeconds, $limit->slidingWindow);
}

return $next($job);
Expand Down Expand Up @@ -135,11 +136,13 @@ public function dontRelease()
* Get the number of seconds that should elapse before the job is retried.
*
* @param string $key
* @param int $decaySeconds
* @param bool $slidingWindow
* @return int
*/
protected function getTimeUntilNextRetry($key)
protected function getTimeUntilNextRetry($key, $decaySeconds = 60, $slidingWindow = false)
{
return $this->limiter->availableIn($key) + 3;
return $this->limiter->availableIn($key, $decaySeconds, $slidingWindow) + 3;
}

/**
Expand Down
16 changes: 10 additions & 6 deletions src/Illuminate/Queue/Middleware/RateLimitedWithRedis.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Illuminate\Container\Container;
use Illuminate\Contracts\Redis\Factory as Redis;
use Illuminate\Redis\Limiters\DurationLimiter;
use Illuminate\Redis\Limiters\SlidingWindowDurationLimiter;
use Illuminate\Support\InteractsWithTime;

class RateLimitedWithRedis extends RateLimited
Expand Down Expand Up @@ -48,7 +49,7 @@ public function __construct($limiterName, ?string $connection = null)
protected function handleJob($job, $next, array $limits)
{
foreach ($limits as $limit) {
if ($this->tooManyAttempts($limit->key, $limit->maxAttempts, $limit->decaySeconds)) {
if ($this->tooManyAttempts($limit->key, $limit->maxAttempts, $limit->decaySeconds, $limit->slidingWindow)) {
return $this->shouldRelease
? $job->release($this->releaseAfter ?: $this->getTimeUntilNextRetry($limit->key))
: false;
Expand All @@ -64,17 +65,18 @@ protected function handleJob($job, $next, array $limits)
* @param string $key
* @param int $maxAttempts
* @param int $decaySeconds
* @param bool $slidingWindow
* @return bool
*/
protected function tooManyAttempts($key, $maxAttempts, $decaySeconds)
protected function tooManyAttempts($key, $maxAttempts, $decaySeconds, $slidingWindow = false)
{
$redis = Container::getInstance()
->make(Redis::class)
->connection($this->connectionName);

$limiter = new DurationLimiter(
$redis, $key, $maxAttempts, $decaySeconds
);
$limiter = $slidingWindow
? new SlidingWindowDurationLimiter($redis, $key, $maxAttempts, $decaySeconds)
: new DurationLimiter($redis, $key, $maxAttempts, $decaySeconds);

return tap(! $limiter->acquire(), function () use ($key, $limiter) {
$this->decaysAt[$key] = $limiter->decaysAt;
Expand All @@ -85,9 +87,11 @@ protected function tooManyAttempts($key, $maxAttempts, $decaySeconds)
* Get the number of seconds that should elapse before the job is retried.
*
* @param string $key
* @param int $decaySeconds
* @param bool $slidingWindow
* @return int
*/
protected function getTimeUntilNextRetry($key)
protected function getTimeUntilNextRetry($key, $decaySeconds = 60, $slidingWindow = false)
{
return ($this->decaysAt[$key] - $this->currentTime()) + 3;
}
Expand Down
Loading
Loading