Skip to content

Commit bbf0b4c

Browse files
[10.x] Throttle exceptions (#48391)
* Throttle exceptions * Lint * Prefix key * formatting --------- Co-authored-by: Taylor Otwell <taylor@laravel.com>
1 parent 9e1c516 commit bbf0b4c

File tree

2 files changed

+277
-1
lines changed

2 files changed

+277
-1
lines changed

src/Illuminate/Foundation/Exceptions/Handler.php

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
use Exception;
77
use Illuminate\Auth\Access\AuthorizationException;
88
use Illuminate\Auth\AuthenticationException;
9+
use Illuminate\Cache\RateLimiter;
10+
use Illuminate\Cache\RateLimiting\Limit;
11+
use Illuminate\Cache\RateLimiting\Unlimited;
912
use Illuminate\Console\View\Components\BulletList;
1013
use Illuminate\Console\View\Components\Error;
1114
use Illuminate\Contracts\Container\Container;
@@ -24,6 +27,7 @@
2427
use Illuminate\Session\TokenMismatchException;
2528
use Illuminate\Support\Arr;
2629
use Illuminate\Support\Facades\Auth;
30+
use Illuminate\Support\Lottery;
2731
use Illuminate\Support\Reflector;
2832
use Illuminate\Support\Traits\ReflectsClosures;
2933
use Illuminate\Support\ViewErrorBag;
@@ -90,6 +94,13 @@ class Handler implements ExceptionHandlerContract
9094
*/
9195
protected $exceptionMap = [];
9296

97+
/**
98+
* Indicates that throttled keys should be hashed.
99+
*
100+
* @var bool
101+
*/
102+
protected $hashThrottleKeys = true;
103+
93104
/**
94105
* A list of the internal exception types that should not be reported.
95106
*
@@ -332,7 +343,37 @@ protected function shouldntReport(Throwable $e)
332343

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

335-
return ! is_null(Arr::first($dontReport, fn ($type) => $e instanceof $type));
346+
if (! is_null(Arr::first($dontReport, fn ($type) => $e instanceof $type))) {
347+
return true;
348+
}
349+
350+
return rescue(fn () => with($this->throttle($e), function ($throttle) use ($e) {
351+
if ($throttle instanceof Unlimited || $throttle === null) {
352+
return false;
353+
}
354+
355+
if ($throttle instanceof Lottery) {
356+
return ! $throttle($e);
357+
}
358+
359+
return ! $this->container->make(RateLimiter::class)->attempt(
360+
with($throttle->key ?: 'illuminate:foundation:exceptions:'.$e::class, fn ($key) => $this->hashThrottleKeys ? md5($key) : $key),
361+
$throttle->maxAttempts,
362+
fn () => true,
363+
$throttle->decayMinutes
364+
);
365+
}), rescue: false, report: false);
366+
}
367+
368+
/**
369+
* Throttle the given exception.
370+
*
371+
* @param \Throwable $e
372+
* @return \Illuminate\Support\Lottery|\Illuminate\Cache\RateLimiting\Limit|null
373+
*/
374+
protected function throttle(Throwable $e)
375+
{
376+
return Limit::none();
336377
}
337378

338379
/**

tests/Foundation/FoundationExceptionsHandlerTest.php

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22

33
namespace Illuminate\Tests\Foundation;
44

5+
use Closure;
56
use Exception;
7+
use Illuminate\Cache\ArrayStore;
8+
use Illuminate\Cache\NullStore;
9+
use Illuminate\Cache\RateLimiter;
10+
use Illuminate\Cache\RateLimiting\Limit;
11+
use Illuminate\Cache\Repository;
612
use Illuminate\Config\Repository as Config;
713
use Illuminate\Container\Container;
814
use Illuminate\Contracts\Routing\ResponseFactory as ResponseFactoryContract;
@@ -15,6 +21,8 @@
1521
use Illuminate\Http\Request;
1622
use Illuminate\Routing\Redirector;
1723
use Illuminate\Routing\ResponseFactory;
24+
use Illuminate\Support\Carbon;
25+
use Illuminate\Support\Lottery;
1826
use Illuminate\Support\MessageBag;
1927
use Illuminate\Testing\Assert;
2028
use Illuminate\Validation\ValidationException;
@@ -470,6 +478,233 @@ public function testItCanDedupeExceptions()
470478

471479
$this->assertSame($reported, [$one, $two]);
472480
}
481+
482+
public function testItDoesNotThrottleExceptionsByDefault()
483+
{
484+
$reported = [];
485+
$this->handler->reportable(function (\Throwable $e) use (&$reported) {
486+
$reported[] = $e;
487+
488+
return false;
489+
});
490+
491+
for ($i = 0; $i < 100; $i++) {
492+
$this->handler->report(new RuntimeException("Exception {$i}"));
493+
}
494+
495+
$this->assertCount(100, $reported);
496+
}
497+
498+
public function testItDoesNotThrottleExceptionsWhenNullReturned()
499+
{
500+
$handler = new class($this->container) extends Handler
501+
{
502+
protected function throttle($e)
503+
{
504+
//
505+
}
506+
};
507+
$reported = [];
508+
$handler->reportable(function (\Throwable $e) use (&$reported) {
509+
$reported[] = $e;
510+
511+
return false;
512+
});
513+
514+
for ($i = 0; $i < 100; $i++) {
515+
$handler->report(new RuntimeException("Exception {$i}"));
516+
}
517+
518+
$this->assertCount(100, $reported);
519+
}
520+
521+
public function testItDoesNotThrottleExceptionsWhenUnlimitedLimit()
522+
{
523+
$handler = new class($this->container) extends Handler
524+
{
525+
protected function throttle($e)
526+
{
527+
return Limit::none();
528+
}
529+
};
530+
$reported = [];
531+
$handler->reportable(function (\Throwable $e) use (&$reported) {
532+
$reported[] = $e;
533+
534+
return false;
535+
});
536+
537+
for ($i = 0; $i < 100; $i++) {
538+
$handler->report(new RuntimeException("Exception {$i}"));
539+
}
540+
541+
$this->assertCount(100, $reported);
542+
}
543+
544+
public function testItCanSampleExceptionsByClass()
545+
{
546+
$handler = new class($this->container) extends Handler
547+
{
548+
protected function throttle($e)
549+
{
550+
return match (true) {
551+
$e instanceof RuntimeException => Lottery::odds(2, 10),
552+
default => parent::throttle($e),
553+
};
554+
}
555+
};
556+
Lottery::forceResultWithSequence([
557+
true, false, false, false, false,
558+
true, false, false, false, false,
559+
]);
560+
$reported = [];
561+
$handler->reportable(function (\Throwable $e) use (&$reported) {
562+
$reported[] = $e;
563+
564+
return false;
565+
});
566+
567+
for ($i = 0; $i < 10; $i++) {
568+
$handler->report(new Exception("Exception {$i}"));
569+
$handler->report(new RuntimeException("RuntimeException {$i}"));
570+
}
571+
572+
[$runtimeExceptions, $baseExceptions] = collect($reported)->partition(fn ($e) => $e instanceof RuntimeException);
573+
$this->assertCount(10, $baseExceptions);
574+
$this->assertCount(2, $runtimeExceptions);
575+
}
576+
577+
public function testItRescuesExceptionsWhileThrottlingAndReports()
578+
{
579+
$handler = new class($this->container) extends Handler
580+
{
581+
protected function throttle($e)
582+
{
583+
throw new RuntimeException('Something went wrong in the throttle method.');
584+
}
585+
};
586+
$reported = [];
587+
$handler->reportable(function (\Throwable $e) use (&$reported) {
588+
$reported[] = $e;
589+
590+
return false;
591+
});
592+
593+
$handler->report(new Exception('Something in the app went wrong.'));
594+
595+
$this->assertCount(1, $reported);
596+
$this->assertSame('Something in the app went wrong.', $reported[0]->getMessage());
597+
}
598+
599+
public function testItRescuesExceptionsIfThereIsAnIssueResolvingTheRateLimiter()
600+
{
601+
$handler = new class($this->container) extends Handler
602+
{
603+
protected function throttle($e)
604+
{
605+
return Limit::perDay(1);
606+
}
607+
};
608+
$reported = [];
609+
$handler->reportable(function (\Throwable $e) use (&$reported) {
610+
$reported[] = $e;
611+
612+
return false;
613+
});
614+
$resolved = false;
615+
$this->container->bind(RateLimiter::class, function () use (&$resolved) {
616+
$resolved = true;
617+
618+
throw new Exception('Error resolving rate limiter.');
619+
});
620+
621+
$handler->report(new Exception('Something in the app went wrong.'));
622+
623+
$this->assertTrue($resolved);
624+
$this->assertCount(1, $reported);
625+
$this->assertSame('Something in the app went wrong.', $reported[0]->getMessage());
626+
}
627+
628+
public function testItRescuesExceptionsIfThereIsAnIssueWithTheRateLimiter()
629+
{
630+
$handler = new class($this->container) extends Handler
631+
{
632+
protected function throttle($e)
633+
{
634+
return Limit::perDay(1);
635+
}
636+
};
637+
$reported = [];
638+
$handler->reportable(function (\Throwable $e) use (&$reported) {
639+
$reported[] = $e;
640+
641+
return false;
642+
});
643+
$this->container->instance(RateLimiter::class, $limiter = new class(new Repository(new NullStore)) extends RateLimiter
644+
{
645+
public $attempted = false;
646+
647+
public function attempt($key, $maxAttempts, Closure $callback, $decaySeconds = 60)
648+
{
649+
$this->attempted = true;
650+
651+
throw new Exception('Unable to connect to Redis.');
652+
}
653+
});
654+
655+
$handler->report(new Exception('Something in the app went wrong.'));
656+
657+
$this->assertTrue($limiter->attempted);
658+
$this->assertCount(1, $reported);
659+
$this->assertSame('Something in the app went wrong.', $reported[0]->getMessage());
660+
}
661+
662+
public function testItCanRateLimitExceptions()
663+
{
664+
$handler = new class($this->container) extends Handler
665+
{
666+
protected function throttle($e)
667+
{
668+
return Limit::perMinute(7);
669+
}
670+
};
671+
$reported = [];
672+
$handler->reportable(function (\Throwable $e) use (&$reported) {
673+
$reported[] = $e;
674+
675+
return false;
676+
});
677+
$this->container->instance(RateLimiter::class, $limiter = new class(new Repository(new ArrayStore)) extends RateLimiter
678+
{
679+
public $attempted = 0;
680+
681+
public function attempt($key, $maxAttempts, Closure $callback, $decaySeconds = 60)
682+
{
683+
$this->attempted++;
684+
685+
return parent::attempt(...func_get_args());
686+
}
687+
});
688+
Carbon::setTestNow(Carbon::now()->startOfDay());
689+
690+
for ($i = 0; $i < 100; $i++) {
691+
$handler->report(new Exception('Something in the app went wrong.'));
692+
}
693+
694+
$this->assertSame(100, $limiter->attempted);
695+
$this->assertCount(7, $reported);
696+
$this->assertSame('Something in the app went wrong.', $reported[0]->getMessage());
697+
698+
Carbon::setTestNow(Carbon::now()->addMinute());
699+
700+
for ($i = 0; $i < 100; $i++) {
701+
$handler->report(new Exception('Something in the app went wrong.'));
702+
}
703+
704+
$this->assertSame(200, $limiter->attempted);
705+
$this->assertCount(14, $reported);
706+
$this->assertSame('Something in the app went wrong.', $reported[0]->getMessage());
707+
}
473708
}
474709

475710
class CustomException extends Exception

0 commit comments

Comments
 (0)