Skip to content
Merged
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
20 changes: 20 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;

/**
* The after callback used to determine if the limiter should be hit.
*
* @var ?callable
*/
public $afterCallback = null;

/**
* The response generator callback.
*
Expand Down Expand Up @@ -129,6 +136,19 @@ public function by($key)
return $this;
}

/**
* Set the callback to determine if the limiter should be hit.
*
* @param callable $callback
* @return $this
*/
public function after($callback)
{
$this->afterCallback = $callback;

return $this;
}

/**
* Set the callback that should generate the response when the limit is exceeded.
*
Expand Down
10 changes: 9 additions & 1 deletion src/Illuminate/Routing/Middleware/ThrottleRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes
'key' => $prefix.$this->resolveRequestSignature($request),
'maxAttempts' => $this->resolveMaxAttempts($request, $maxAttempts),
'decaySeconds' => 60 * $decayMinutes,
'afterCallback' => null,
'responseCallback' => null,
],
]
Expand Down Expand Up @@ -131,6 +132,7 @@ protected function handleRequestUsingNamedLimiter($request, Closure $next, $limi
'key' => self::$shouldHashKeys ? md5($limiterName.$limit->key) : $limiterName.':'.$limit->key,
'maxAttempts' => $limit->maxAttempts,
'decaySeconds' => $limit->decaySeconds,
'afterCallback' => $limit->afterCallback,
'responseCallback' => $limit->responseCallback,
];
})->all()
Expand All @@ -154,12 +156,18 @@ protected function handleRequest($request, Closure $next, array $limits)
throw $this->buildException($request, $limit->key, $limit->maxAttempts, $limit->responseCallback);
}

$this->limiter->hit($limit->key, $limit->decaySeconds);
if (! $limit->afterCallback) {
$this->limiter->hit($limit->key, $limit->decaySeconds);
}
}

$response = $next($request);

foreach ($limits as $limit) {
if ($limit->afterCallback && ($limit->afterCallback)($response)) {
$this->limiter->hit($limit->key, $limit->decaySeconds);
}

$response = $this->addHeaders(
$response,
$limit->maxAttempts,
Expand Down
80 changes: 80 additions & 0 deletions tests/Integration/Http/ThrottleRequestsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,19 @@
use Illuminate\Foundation\Auth\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Http\Request;
use Illuminate\Routing\Exceptions\MissingRateLimiterException;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\RateLimiter as RateLimiterFacade;
use Illuminate\Support\Facades\Route;
use Orchestra\Testbench\Attributes\WithConfig;
use Orchestra\Testbench\Attributes\WithMigration;
use Orchestra\Testbench\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\HttpFoundation\Response;
use Throwable;

#[WithConfig('hashing.driver', 'bcrypt')]
Expand Down Expand Up @@ -410,6 +415,81 @@ public function testItFallbacksToUserAccessorWhenThereIsNoNamedLimiterWhenAuthen
$this->assertEquals(Carbon::now()->addSeconds(2)->getTimestamp(), $e->getHeaders()['X-RateLimit-Reset']);
}
}

public function testItCanThrottleBasedOnResponse()
{
RateLimiterFacade::for('throttle-not-found', function (Request $request) {
return Limit::perMinute(1)->after(fn ($response) => $response->status() === 404);
});
Route::get('/', fn () => match(request('status')) {
'404' => abort(404),
default => 'ok',
})->middleware(ThrottleRequests::using('throttle-not-found'));

$this->travelTo('2000-01-01 00:00:00');
$this->get('?status=404')->assertNotFound();
$this->get('?status=404')->assertTooManyRequests();
$this->get('?status=404')->assertTooManyRequests();

$this->travelTo('2000-01-01 00:00:59');
$this->get('?status=404')->assertTooManyRequests();
$this->get('?status=404')->assertTooManyRequests();

$this->travelTo('2000-01-01 00:01:00');
$this->get('?status=404')->assertNotFound();
$this->get('?status=404')->assertTooManyRequests();
$this->get('?status=404')->assertTooManyRequests();

$this->travelTo('2000-01-01 00:01:59');
$this->get('?status=404')->assertTooManyRequests();
$this->get('?status=404')->assertTooManyRequests();

$this->travelTo('2000-01-01 00:02:00');
$this->get('?status=404')->assertNotFound();
$this->get('?status=404')->assertTooManyRequests();
$this->get('?status=404')->assertTooManyRequests();
}

public function testItDoesNotHitLimiterUntilResponseHasBeenGenerated()
{
ThrottleRequests::shouldHashKeys(false);
RateLimiterFacade::for('throttle-not-found', function (Request $request) {
return Limit::perMinute(1)->after(fn ($response) => $response->status() === 404);
});
$duringRequest = null;
Route::get('/', function () use (&$duringRequest) {
$duringRequest = [
Cache::get('throttle-not-found:'),
Cache::get('throttle-not-found::timer'),
];

abort(404);
})->middleware(ThrottleRequests::using('throttle-not-found'));

$this->travelTo('2000-01-01 00:00:00');
$this->get('?status=404')->assertNotFound();

$this->assertSame([null, null], $duringRequest);
$this->assertSame([1, 946684860], [
Cache::get('throttle-not-found:'),
Cache::get('throttle-not-found::timer'),
]);
}

public function testItReturnsConfiguredResponseWhenUsingAfterLimit(): void
{
ThrottleRequests::shouldHashKeys(false);
RateLimiterFacade::for('throttle-not-found', function (Request $request) {
return Limit::perMinute(1)
->after(fn ($response) => $response->status() === 404)
->response(fn () => response('ah ah ah', status: 429));
});
Route::get('/', fn () => abort(404))->middleware(ThrottleRequests::using('throttle-not-found'));

$this->travelTo('2000-01-01 00:00:00');
$this->get('?status=404')->assertNotFound();
$this->get('?status=404')->assertTooManyRequests()->assertContent('ah ah ah');
}
}

class UserWithAccessor extends User
Expand Down