Skip to content

Commit e32b3a6

Browse files
[12.x] Introduce "after" rate limiting (#57125)
* [12.x] Allow rate limit hits to only be recorded after success * Make after callback generic --------- Co-authored-by: Joseph Silber <[email protected]>
1 parent 18cd6ab commit e32b3a6

File tree

3 files changed

+109
-1
lines changed

3 files changed

+109
-1
lines changed

src/Illuminate/Cache/RateLimiting/Limit.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ class Limit
2525
*/
2626
public $decaySeconds;
2727

28+
/**
29+
* The after callback used to determine if the limiter should be hit.
30+
*
31+
* @var ?callable
32+
*/
33+
public $afterCallback = null;
34+
2835
/**
2936
* The response generator callback.
3037
*
@@ -129,6 +136,19 @@ public function by($key)
129136
return $this;
130137
}
131138

139+
/**
140+
* Set the callback to determine if the limiter should be hit.
141+
*
142+
* @param callable $callback
143+
* @return $this
144+
*/
145+
public function after($callback)
146+
{
147+
$this->afterCallback = $callback;
148+
149+
return $this;
150+
}
151+
132152
/**
133153
* Set the callback that should generate the response when the limit is exceeded.
134154
*

src/Illuminate/Routing/Middleware/ThrottleRequests.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes
9898
'key' => $prefix.$this->resolveRequestSignature($request),
9999
'maxAttempts' => $this->resolveMaxAttempts($request, $maxAttempts),
100100
'decaySeconds' => 60 * $decayMinutes,
101+
'afterCallback' => null,
101102
'responseCallback' => null,
102103
],
103104
]
@@ -133,6 +134,7 @@ protected function handleRequestUsingNamedLimiter($request, Closure $next, $limi
133134
'key' => self::$shouldHashKeys ? md5($limiterName.$limit->key) : $limiterName.':'.$limit->key,
134135
'maxAttempts' => $limit->maxAttempts,
135136
'decaySeconds' => $limit->decaySeconds,
137+
'afterCallback' => $limit->afterCallback,
136138
'responseCallback' => $limit->responseCallback,
137139
];
138140
})->all()
@@ -156,12 +158,18 @@ protected function handleRequest($request, Closure $next, array $limits)
156158
throw $this->buildException($request, $limit->key, $limit->maxAttempts, $limit->responseCallback);
157159
}
158160

159-
$this->limiter->hit($limit->key, $limit->decaySeconds);
161+
if (! $limit->afterCallback) {
162+
$this->limiter->hit($limit->key, $limit->decaySeconds);
163+
}
160164
}
161165

162166
$response = $next($request);
163167

164168
foreach ($limits as $limit) {
169+
if ($limit->afterCallback && ($limit->afterCallback)($response)) {
170+
$this->limiter->hit($limit->key, $limit->decaySeconds);
171+
}
172+
165173
$response = $this->addHeaders(
166174
$response,
167175
$limit->maxAttempts,

tests/Integration/Http/ThrottleRequestsTest.php

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,19 @@
1010
use Illuminate\Foundation\Auth\User;
1111
use Illuminate\Foundation\Testing\RefreshDatabase;
1212
use Illuminate\Http\Exceptions\ThrottleRequestsException;
13+
use Illuminate\Http\Request;
1314
use Illuminate\Routing\Exceptions\MissingRateLimiterException;
1415
use Illuminate\Routing\Middleware\ThrottleRequests;
1516
use Illuminate\Support\Carbon;
17+
use Illuminate\Support\Facades\Cache;
18+
use Illuminate\Support\Facades\Date;
19+
use Illuminate\Support\Facades\RateLimiter as RateLimiterFacade;
1620
use Illuminate\Support\Facades\Route;
1721
use Orchestra\Testbench\Attributes\WithConfig;
1822
use Orchestra\Testbench\Attributes\WithMigration;
1923
use Orchestra\Testbench\TestCase;
2024
use PHPUnit\Framework\Attributes\DataProvider;
25+
use Symfony\Component\HttpFoundation\Response;
2126
use Throwable;
2227

2328
#[WithConfig('hashing.driver', 'bcrypt')]
@@ -410,6 +415,81 @@ public function testItFallbacksToUserAccessorWhenThereIsNoNamedLimiterWhenAuthen
410415
$this->assertEquals(Carbon::now()->addSeconds(2)->getTimestamp(), $e->getHeaders()['X-RateLimit-Reset']);
411416
}
412417
}
418+
419+
public function testItCanThrottleBasedOnResponse()
420+
{
421+
RateLimiterFacade::for('throttle-not-found', function (Request $request) {
422+
return Limit::perMinute(1)->after(fn ($response) => $response->status() === 404);
423+
});
424+
Route::get('/', fn () => match(request('status')) {
425+
'404' => abort(404),
426+
default => 'ok',
427+
})->middleware(ThrottleRequests::using('throttle-not-found'));
428+
429+
$this->travelTo('2000-01-01 00:00:00');
430+
$this->get('?status=404')->assertNotFound();
431+
$this->get('?status=404')->assertTooManyRequests();
432+
$this->get('?status=404')->assertTooManyRequests();
433+
434+
$this->travelTo('2000-01-01 00:00:59');
435+
$this->get('?status=404')->assertTooManyRequests();
436+
$this->get('?status=404')->assertTooManyRequests();
437+
438+
$this->travelTo('2000-01-01 00:01:00');
439+
$this->get('?status=404')->assertNotFound();
440+
$this->get('?status=404')->assertTooManyRequests();
441+
$this->get('?status=404')->assertTooManyRequests();
442+
443+
$this->travelTo('2000-01-01 00:01:59');
444+
$this->get('?status=404')->assertTooManyRequests();
445+
$this->get('?status=404')->assertTooManyRequests();
446+
447+
$this->travelTo('2000-01-01 00:02:00');
448+
$this->get('?status=404')->assertNotFound();
449+
$this->get('?status=404')->assertTooManyRequests();
450+
$this->get('?status=404')->assertTooManyRequests();
451+
}
452+
453+
public function testItDoesNotHitLimiterUntilResponseHasBeenGenerated()
454+
{
455+
ThrottleRequests::shouldHashKeys(false);
456+
RateLimiterFacade::for('throttle-not-found', function (Request $request) {
457+
return Limit::perMinute(1)->after(fn ($response) => $response->status() === 404);
458+
});
459+
$duringRequest = null;
460+
Route::get('/', function () use (&$duringRequest) {
461+
$duringRequest = [
462+
Cache::get('throttle-not-found:'),
463+
Cache::get('throttle-not-found::timer'),
464+
];
465+
466+
abort(404);
467+
})->middleware(ThrottleRequests::using('throttle-not-found'));
468+
469+
$this->travelTo('2000-01-01 00:00:00');
470+
$this->get('?status=404')->assertNotFound();
471+
472+
$this->assertSame([null, null], $duringRequest);
473+
$this->assertSame([1, 946684860], [
474+
Cache::get('throttle-not-found:'),
475+
Cache::get('throttle-not-found::timer'),
476+
]);
477+
}
478+
479+
public function testItReturnsConfiguredResponseWhenUsingAfterLimit(): void
480+
{
481+
ThrottleRequests::shouldHashKeys(false);
482+
RateLimiterFacade::for('throttle-not-found', function (Request $request) {
483+
return Limit::perMinute(1)
484+
->after(fn ($response) => $response->status() === 404)
485+
->response(fn () => response('ah ah ah', status: 429));
486+
});
487+
Route::get('/', fn () => abort(404))->middleware(ThrottleRequests::using('throttle-not-found'));
488+
489+
$this->travelTo('2000-01-01 00:00:00');
490+
$this->get('?status=404')->assertNotFound();
491+
$this->get('?status=404')->assertTooManyRequests()->assertContent('ah ah ah');
492+
}
413493
}
414494

415495
class UserWithAccessor extends User

0 commit comments

Comments
 (0)