Skip to content

Commit 9867436

Browse files
[11.x] Allow list of rate limiters without requiring unique keys (#53177)
* Ensure rate limits have unique keys * Update Limit.php --------- Co-authored-by: Taylor Otwell <[email protected]>
1 parent a6b0541 commit 9867436

File tree

3 files changed

+118
-1
lines changed

3 files changed

+118
-1
lines changed

src/Illuminate/Cache/RateLimiter.php

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,33 @@ public function limiter($name)
6363
{
6464
$resolvedName = $this->resolveLimiterName($name);
6565

66-
return $this->limiters[$resolvedName] ?? null;
66+
$limiter = $this->limiters[$resolvedName] ?? null;
67+
68+
if (! is_callable($limiter)) {
69+
return;
70+
}
71+
72+
return function (...$args) use ($limiter) {
73+
$result = $limiter(...$args);
74+
75+
if (! is_array($result)) {
76+
return $result;
77+
}
78+
79+
$duplicates = collect($result)->duplicates('key');
80+
81+
if ($duplicates->isEmpty()) {
82+
return $result;
83+
}
84+
85+
foreach ($result as $limit) {
86+
if ($duplicates->contains($limit->key)) {
87+
$limit->key = $limit->fallbackKey();
88+
}
89+
}
90+
91+
return $result;
92+
};
6793
}
6894

6995
/**

src/Illuminate/Cache/RateLimiting/Limit.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,4 +142,16 @@ public function response(callable $callback)
142142

143143
return $this;
144144
}
145+
146+
/**
147+
* Get a potential fallback key for the limit.
148+
*
149+
* @return string
150+
*/
151+
public function fallbackKey()
152+
{
153+
$prefix = $this->key ? '' : "{$this->key}:";
154+
155+
return "{$prefix}attempts:{$this->maxAttempts}:decay:{$this->decaySeconds}";
156+
}
145157
}

tests/Integration/Http/ThrottleRequestsTest.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,85 @@ public function testItCanThrottlePerSecond()
242242
$response->assertOk();
243243
}
244244

245+
public function testItCanCombineRateLimitsWithoutSpecifyingUniqueKeys()
246+
{
247+
$rateLimiter = Container::getInstance()->make(RateLimiter::class);
248+
$rateLimiter->for('test', fn () => [
249+
Limit::perSecond(3),
250+
Limit::perMinute(5),
251+
]);
252+
Route::get('/', fn () => 'ok')->middleware(ThrottleRequests::using('test'));
253+
254+
Carbon::setTestNow('2000-01-01 00:00:00.000');
255+
256+
// Make 3 requests, each a 100ms apart, that should all be successful.
257+
258+
for ($i = 0; $i < 3; $i++) {
259+
match ($i) {
260+
0 => $this->assertSame('2000-01-01 00:00:00.000', now()->toDateTimeString('m')),
261+
1 => $this->assertSame('2000-01-01 00:00:00.100', now()->toDateTimeString('m')),
262+
2 => $this->assertSame('2000-01-01 00:00:00.200', now()->toDateTimeString('m')),
263+
};
264+
265+
$response = $this->get('/');
266+
$response->assertOk();
267+
$response->assertContent('ok');
268+
269+
Carbon::setTestNow(now()->addMilliseconds(100));
270+
}
271+
272+
// It is now 300 milliseconds past and we will make another request
273+
// that should be rate limited.
274+
275+
$this->assertSame('2000-01-01 00:00:00.300', now()->toDateTimeString('m'));
276+
277+
$response = $this->get('/');
278+
$response->assertStatus(429);
279+
$response->assertHeader('Retry-After', 1);
280+
$response->assertHeader('X-RateLimit-Reset', now()->addSecond()->timestamp);
281+
$response->assertHeader('X-RateLimit-Limit', 3);
282+
$response->assertHeader('X-RateLimit-Remaining', 0);
283+
284+
// We will now make it the very end of the second, to check boundaries,
285+
// and make another request that should be rate limited and tell us to
286+
// try again in 1 second.
287+
Carbon::setTestNow('2000-01-01 00:00:00.999');
288+
289+
$response = $this->get('/');
290+
$response->assertHeader('Retry-After', 1);
291+
$response->assertHeader('X-RateLimit-Reset', now()->addSecond()->timestamp);
292+
$response->assertHeader('X-RateLimit-Limit', 3);
293+
$response->assertHeader('X-RateLimit-Remaining', 0);
294+
295+
// We now tick over into the next second. We should now be able to make
296+
// another two requests before the per minute rate limit kicks in.
297+
Carbon::setTestNow('2000-01-01 00:00:01.000');
298+
299+
for ($i = 0; $i < 2; $i++) {
300+
match ($i) {
301+
0 => $this->assertSame('2000-01-01 00:00:01.000', now()->toDateTimeString('m')),
302+
1 => $this->assertSame('2000-01-01 00:00:01.100', now()->toDateTimeString('m')),
303+
};
304+
305+
$response = $this->get('/');
306+
$response->assertOk();
307+
$response->assertContent('ok');
308+
309+
Carbon::setTestNow(now()->addMilliseconds(100));
310+
}
311+
312+
// The per minute rate limiter should now fail.
313+
314+
$this->assertSame('2000-01-01 00:00:01.200', now()->toDateTimeString('m'));
315+
316+
$response = $this->get('/');
317+
$response->assertStatus(429);
318+
$response->assertHeader('Retry-After', 59);
319+
$response->assertHeader('X-RateLimit-Reset', now()->addSeconds(59)->timestamp);
320+
$response->assertHeader('X-RateLimit-Limit', 5);
321+
$response->assertHeader('X-RateLimit-Remaining', 0);
322+
}
323+
245324
public function testItFailsIfNamedLimiterDoesNotExist()
246325
{
247326
$this->expectException(MissingRateLimiterException::class);

0 commit comments

Comments
 (0)