Skip to content

Commit

Permalink
[11.x] Allow list of rate limiters without requiring unique keys (#53177
Browse files Browse the repository at this point in the history
)

* Ensure rate limits have unique keys

* Update Limit.php

---------

Co-authored-by: Taylor Otwell <taylor@laravel.com>
  • Loading branch information
timacdonald and taylorotwell authored Oct 16, 2024
1 parent a6b0541 commit 9867436
Show file tree
Hide file tree
Showing 3 changed files with 118 additions and 1 deletion.
28 changes: 27 additions & 1 deletion src/Illuminate/Cache/RateLimiter.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,33 @@ public function limiter($name)
{
$resolvedName = $this->resolveLimiterName($name);

return $this->limiters[$resolvedName] ?? null;
$limiter = $this->limiters[$resolvedName] ?? null;

if (! is_callable($limiter)) {
return;
}

return function (...$args) use ($limiter) {

This comment has been minimized.

Copy link
@siarheipashkevich

siarheipashkevich Oct 17, 2024

Contributor

static?

$result = $limiter(...$args);

if (! is_array($result)) {
return $result;
}

$duplicates = collect($result)->duplicates('key');

if ($duplicates->isEmpty()) {
return $result;
}

foreach ($result as $limit) {
if ($duplicates->contains($limit->key)) {
$limit->key = $limit->fallbackKey();
}
}

return $result;
};
}

/**
Expand Down
12 changes: 12 additions & 0 deletions src/Illuminate/Cache/RateLimiting/Limit.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,16 @@ public function response(callable $callback)

return $this;
}

/**
* Get a potential fallback key for the limit.
*
* @return string
*/
public function fallbackKey()
{
$prefix = $this->key ? '' : "{$this->key}:";

return "{$prefix}attempts:{$this->maxAttempts}:decay:{$this->decaySeconds}";
}
}
79 changes: 79 additions & 0 deletions tests/Integration/Http/ThrottleRequestsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,85 @@ public function testItCanThrottlePerSecond()
$response->assertOk();
}

public function testItCanCombineRateLimitsWithoutSpecifyingUniqueKeys()
{
$rateLimiter = Container::getInstance()->make(RateLimiter::class);
$rateLimiter->for('test', fn () => [
Limit::perSecond(3),
Limit::perMinute(5),
]);
Route::get('/', fn () => 'ok')->middleware(ThrottleRequests::using('test'));

Carbon::setTestNow('2000-01-01 00:00:00.000');

// Make 3 requests, each a 100ms apart, that should all be successful.

for ($i = 0; $i < 3; $i++) {
match ($i) {
0 => $this->assertSame('2000-01-01 00:00:00.000', now()->toDateTimeString('m')),
1 => $this->assertSame('2000-01-01 00:00:00.100', now()->toDateTimeString('m')),
2 => $this->assertSame('2000-01-01 00:00:00.200', now()->toDateTimeString('m')),
};

$response = $this->get('/');
$response->assertOk();
$response->assertContent('ok');

Carbon::setTestNow(now()->addMilliseconds(100));
}

// It is now 300 milliseconds past and we will make another request
// that should be rate limited.

$this->assertSame('2000-01-01 00:00:00.300', now()->toDateTimeString('m'));

$response = $this->get('/');
$response->assertStatus(429);
$response->assertHeader('Retry-After', 1);
$response->assertHeader('X-RateLimit-Reset', now()->addSecond()->timestamp);
$response->assertHeader('X-RateLimit-Limit', 3);
$response->assertHeader('X-RateLimit-Remaining', 0);

// We will now make it the very end of the second, to check boundaries,
// and make another request that should be rate limited and tell us to
// try again in 1 second.
Carbon::setTestNow('2000-01-01 00:00:00.999');

$response = $this->get('/');
$response->assertHeader('Retry-After', 1);
$response->assertHeader('X-RateLimit-Reset', now()->addSecond()->timestamp);
$response->assertHeader('X-RateLimit-Limit', 3);
$response->assertHeader('X-RateLimit-Remaining', 0);

// We now tick over into the next second. We should now be able to make
// another two requests before the per minute rate limit kicks in.
Carbon::setTestNow('2000-01-01 00:00:01.000');

for ($i = 0; $i < 2; $i++) {
match ($i) {
0 => $this->assertSame('2000-01-01 00:00:01.000', now()->toDateTimeString('m')),
1 => $this->assertSame('2000-01-01 00:00:01.100', now()->toDateTimeString('m')),
};

$response = $this->get('/');
$response->assertOk();
$response->assertContent('ok');

Carbon::setTestNow(now()->addMilliseconds(100));
}

// The per minute rate limiter should now fail.

$this->assertSame('2000-01-01 00:00:01.200', now()->toDateTimeString('m'));

$response = $this->get('/');
$response->assertStatus(429);
$response->assertHeader('Retry-After', 59);
$response->assertHeader('X-RateLimit-Reset', now()->addSeconds(59)->timestamp);
$response->assertHeader('X-RateLimit-Limit', 5);
$response->assertHeader('X-RateLimit-Remaining', 0);
}

public function testItFailsIfNamedLimiterDoesNotExist()
{
$this->expectException(MissingRateLimiterException::class);
Expand Down

0 comments on commit 9867436

Please sign in to comment.