Skip to content

Commit

Permalink
[10.x] Throttle exceptions (#48391)
Browse files Browse the repository at this point in the history
* Throttle exceptions

* Lint

* Prefix key

* formatting

---------

Co-authored-by: Taylor Otwell <taylor@laravel.com>
  • Loading branch information
timacdonald and taylorotwell authored Sep 20, 2023
1 parent 9e1c516 commit bbf0b4c
Show file tree
Hide file tree
Showing 2 changed files with 277 additions and 1 deletion.
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);
}

/**
* 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

0 comments on commit bbf0b4c

Please sign in to comment.