Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[10.x] Throttle exceptions #48391

Merged
merged 4 commits into from
Sep 20, 2023
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
43 changes: 42 additions & 1 deletion src/Illuminate/Foundation/Exceptions/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Cache\RateLimiter;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Cache\RateLimiting\Unlimited;
use Illuminate\Console\View\Components\BulletList;
use Illuminate\Console\View\Components\Error;
use Illuminate\Contracts\Container\Container;
Expand All @@ -24,6 +27,7 @@
use Illuminate\Session\TokenMismatchException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Lottery;
use Illuminate\Support\Reflector;
use Illuminate\Support\Traits\ReflectsClosures;
use Illuminate\Support\ViewErrorBag;
Expand Down Expand Up @@ -90,6 +94,13 @@ class Handler implements ExceptionHandlerContract
*/
protected $exceptionMap = [];

/**
* Indicates that throttled keys should be hashed.
*
* @var bool
*/
protected $hashThrottleKeys = true;

/**
* A list of the internal exception types that should not be reported.
*
Expand Down Expand Up @@ -332,7 +343,37 @@ protected function shouldntReport(Throwable $e)

$dontReport = array_merge($this->dontReport, $this->internalDontReport);

return ! is_null(Arr::first($dontReport, fn ($type) => $e instanceof $type));
if (! is_null(Arr::first($dontReport, fn ($type) => $e instanceof $type))) {
return true;
}

return rescue(fn () => with($this->throttle($e), function ($throttle) use ($e) {
if ($throttle instanceof Unlimited || $throttle === null) {
return false;
}

if ($throttle instanceof Lottery) {
return ! $throttle($e);
}

return ! $this->container->make(RateLimiter::class)->attempt(
with($throttle->key ?: 'illuminate:foundation:exceptions:'.$e::class, fn ($key) => $this->hashThrottleKeys ? md5($key) : $key),
$throttle->maxAttempts,
fn () => true,
$throttle->decayMinutes
);
}), rescue: false, report: false);
Comment on lines +359 to +365
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a prefix here when there is not a key specified by the user.

I think that it makes sense to only apply the custom prefix when the framework generates the key, but open to feedback.

Alternative would be to have a protected $cachePrefix key with a value that is applied to both framework generated and user generated keys. Users could then change this value as they want.

}

/**
* Throttle the given exception.
*
* @param \Throwable $e
* @return \Illuminate\Support\Lottery|\Illuminate\Cache\RateLimiting\Limit|null
*/
protected function throttle(Throwable $e)
{
return Limit::none();
}

/**
Expand Down
235 changes: 235 additions & 0 deletions tests/Foundation/FoundationExceptionsHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@

namespace Illuminate\Tests\Foundation;

use Closure;
use Exception;
use Illuminate\Cache\ArrayStore;
use Illuminate\Cache\NullStore;
use Illuminate\Cache\RateLimiter;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Cache\Repository;
use Illuminate\Config\Repository as Config;
use Illuminate\Container\Container;
use Illuminate\Contracts\Routing\ResponseFactory as ResponseFactoryContract;
Expand All @@ -15,6 +21,8 @@
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Routing\ResponseFactory;
use Illuminate\Support\Carbon;
use Illuminate\Support\Lottery;
use Illuminate\Support\MessageBag;
use Illuminate\Testing\Assert;
use Illuminate\Validation\ValidationException;
Expand Down Expand Up @@ -470,6 +478,233 @@ public function testItCanDedupeExceptions()

$this->assertSame($reported, [$one, $two]);
}

public function testItDoesNotThrottleExceptionsByDefault()
{
$reported = [];
$this->handler->reportable(function (\Throwable $e) use (&$reported) {
$reported[] = $e;

return false;
});

for ($i = 0; $i < 100; $i++) {
$this->handler->report(new RuntimeException("Exception {$i}"));
}

$this->assertCount(100, $reported);
}

public function testItDoesNotThrottleExceptionsWhenNullReturned()
{
$handler = new class($this->container) extends Handler
{
protected function throttle($e)
{
//
}
};
$reported = [];
$handler->reportable(function (\Throwable $e) use (&$reported) {
$reported[] = $e;

return false;
});

for ($i = 0; $i < 100; $i++) {
$handler->report(new RuntimeException("Exception {$i}"));
}

$this->assertCount(100, $reported);
}

public function testItDoesNotThrottleExceptionsWhenUnlimitedLimit()
{
$handler = new class($this->container) extends Handler
{
protected function throttle($e)
{
return Limit::none();
}
};
$reported = [];
$handler->reportable(function (\Throwable $e) use (&$reported) {
$reported[] = $e;

return false;
});

for ($i = 0; $i < 100; $i++) {
$handler->report(new RuntimeException("Exception {$i}"));
}

$this->assertCount(100, $reported);
}

public function testItCanSampleExceptionsByClass()
{
$handler = new class($this->container) extends Handler
{
protected function throttle($e)
{
return match (true) {
$e instanceof RuntimeException => Lottery::odds(2, 10),
default => parent::throttle($e),
};
}
};
Lottery::forceResultWithSequence([
true, false, false, false, false,
true, false, false, false, false,
]);
$reported = [];
$handler->reportable(function (\Throwable $e) use (&$reported) {
$reported[] = $e;

return false;
});

for ($i = 0; $i < 10; $i++) {
$handler->report(new Exception("Exception {$i}"));
$handler->report(new RuntimeException("RuntimeException {$i}"));
}

[$runtimeExceptions, $baseExceptions] = collect($reported)->partition(fn ($e) => $e instanceof RuntimeException);
$this->assertCount(10, $baseExceptions);
$this->assertCount(2, $runtimeExceptions);
}

public function testItRescuesExceptionsWhileThrottlingAndReports()
{
$handler = new class($this->container) extends Handler
{
protected function throttle($e)
{
throw new RuntimeException('Something went wrong in the throttle method.');
}
};
$reported = [];
$handler->reportable(function (\Throwable $e) use (&$reported) {
$reported[] = $e;

return false;
});

$handler->report(new Exception('Something in the app went wrong.'));

$this->assertCount(1, $reported);
$this->assertSame('Something in the app went wrong.', $reported[0]->getMessage());
}

public function testItRescuesExceptionsIfThereIsAnIssueResolvingTheRateLimiter()
{
$handler = new class($this->container) extends Handler
{
protected function throttle($e)
{
return Limit::perDay(1);
}
};
$reported = [];
$handler->reportable(function (\Throwable $e) use (&$reported) {
$reported[] = $e;

return false;
});
$resolved = false;
$this->container->bind(RateLimiter::class, function () use (&$resolved) {
$resolved = true;

throw new Exception('Error resolving rate limiter.');
});

$handler->report(new Exception('Something in the app went wrong.'));

$this->assertTrue($resolved);
$this->assertCount(1, $reported);
$this->assertSame('Something in the app went wrong.', $reported[0]->getMessage());
}

public function testItRescuesExceptionsIfThereIsAnIssueWithTheRateLimiter()
{
$handler = new class($this->container) extends Handler
{
protected function throttle($e)
{
return Limit::perDay(1);
}
};
$reported = [];
$handler->reportable(function (\Throwable $e) use (&$reported) {
$reported[] = $e;

return false;
});
$this->container->instance(RateLimiter::class, $limiter = new class(new Repository(new NullStore)) extends RateLimiter
{
public $attempted = false;

public function attempt($key, $maxAttempts, Closure $callback, $decaySeconds = 60)
{
$this->attempted = true;

throw new Exception('Unable to connect to Redis.');
}
});

$handler->report(new Exception('Something in the app went wrong.'));

$this->assertTrue($limiter->attempted);
$this->assertCount(1, $reported);
$this->assertSame('Something in the app went wrong.', $reported[0]->getMessage());
}

public function testItCanRateLimitExceptions()
{
$handler = new class($this->container) extends Handler
{
protected function throttle($e)
{
return Limit::perMinute(7);
}
};
$reported = [];
$handler->reportable(function (\Throwable $e) use (&$reported) {
$reported[] = $e;

return false;
});
$this->container->instance(RateLimiter::class, $limiter = new class(new Repository(new ArrayStore)) extends RateLimiter
{
public $attempted = 0;

public function attempt($key, $maxAttempts, Closure $callback, $decaySeconds = 60)
{
$this->attempted++;

return parent::attempt(...func_get_args());
}
});
Carbon::setTestNow(Carbon::now()->startOfDay());

for ($i = 0; $i < 100; $i++) {
$handler->report(new Exception('Something in the app went wrong.'));
}

$this->assertSame(100, $limiter->attempted);
$this->assertCount(7, $reported);
$this->assertSame('Something in the app went wrong.', $reported[0]->getMessage());

Carbon::setTestNow(Carbon::now()->addMinute());

for ($i = 0; $i < 100; $i++) {
$handler->report(new Exception('Something in the app went wrong.'));
}

$this->assertSame(200, $limiter->attempted);
$this->assertCount(14, $reported);
$this->assertSame('Something in the app went wrong.', $reported[0]->getMessage());
}
}

class CustomException extends Exception
Expand Down