From b536d38e9dd3bf257cdaeb07c8e21b22d7b3a43e Mon Sep 17 00:00:00 2001 From: Michael Dzjaparidze Date: Mon, 30 Jun 2025 18:00:29 +0200 Subject: [PATCH 1/3] feat: add failWhen method to throttles exceptions job middleware --- .../Queue/Middleware/ThrottlesExceptions.php | 43 +++++++++++++++++++ .../ThrottlesExceptionsWithRedis.php | 4 ++ 2 files changed, 47 insertions(+) diff --git a/src/Illuminate/Queue/Middleware/ThrottlesExceptions.php b/src/Illuminate/Queue/Middleware/ThrottlesExceptions.php index 5c5c7d04a7ed..3a069edb0800 100644 --- a/src/Illuminate/Queue/Middleware/ThrottlesExceptions.php +++ b/src/Illuminate/Queue/Middleware/ThrottlesExceptions.php @@ -64,6 +64,13 @@ class ThrottlesExceptions */ protected array $deleteWhenCallbacks = []; + /** + * The callbacks that determine if the job should be failed. + * + * @var callable[] + */ + protected array $failWhenCallbacks = []; + /** * The prefix of the rate limiter key. * @@ -122,6 +129,10 @@ public function handle($job, $next) return $job->delete(); } + if ($this->shouldFail($throwable)) { + return $job->fail($throwable); + } + $this->limiter->hit($jobKey, $this->decaySeconds); return $job->release($this->retryAfterMinutes * 60); @@ -156,6 +167,21 @@ public function deleteWhen(callable|string $callback) return $this; } + /** + * Add a callback that should determine if the job should be failed. + * + * @param callable|string $callback + * @return $this + */ + public function failWhen(callable|string $callback) + { + $this->failWhenCallbacks[] = is_string($callback) + ? fn (Throwable $e) => $e instanceof $callback + : $callback; + + return $this; + } + /** * Run the skip / delete callbacks to determine if the job should be deleted for the given exception. * @@ -173,6 +199,23 @@ protected function shouldDelete(Throwable $throwable): bool return false; } + /** + * Run the skip / fail callbacks to determine if the job should be failed for the given exception. + * + * @param Throwable $throwable + * @return bool + */ + protected function shouldFail(Throwable $throwable): bool + { + foreach ($this->failWhenCallbacks as $callback) { + if (call_user_func($callback, $throwable)) { + return true; + } + } + + return false; + } + /** * Set the prefix of the rate limiter key. * diff --git a/src/Illuminate/Queue/Middleware/ThrottlesExceptionsWithRedis.php b/src/Illuminate/Queue/Middleware/ThrottlesExceptionsWithRedis.php index 8c6b78912b0d..cd6cdb7f1fa5 100644 --- a/src/Illuminate/Queue/Middleware/ThrottlesExceptionsWithRedis.php +++ b/src/Illuminate/Queue/Middleware/ThrottlesExceptionsWithRedis.php @@ -62,6 +62,10 @@ public function handle($job, $next) return $job->delete(); } + if ($this->shouldFail($throwable)) { + return $job->fail($throwable); + } + $this->limiter->acquire(); return $job->release($this->retryAfterMinutes * 60); From b0d8ca14ae8cb0ff1eadc0dccd66dd9205a01834 Mon Sep 17 00:00:00 2001 From: Michael Dzjaparidze Date: Mon, 30 Jun 2025 18:28:38 +0200 Subject: [PATCH 2/3] test: add test for failWhen method --- .../Queue/ThrottlesExceptionsTest.php | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/Integration/Queue/ThrottlesExceptionsTest.php b/tests/Integration/Queue/ThrottlesExceptionsTest.php index 19f525afcc8d..859243b49829 100644 --- a/tests/Integration/Queue/ThrottlesExceptionsTest.php +++ b/tests/Integration/Queue/ThrottlesExceptionsTest.php @@ -45,6 +45,11 @@ public function testCircuitCanSkipJob() $this->assertJobWasDeleted(CircuitBreakerSkipJob::class); } + public function testCircuitCanFailJob() + { + $this->assertJobWasFailed(CircuitBreakerFailedJob::class); + } + protected function assertJobWasReleasedImmediately($class) { $class::$handled = false; @@ -108,6 +113,27 @@ protected function assertJobWasDeleted($class) $this->assertTrue($class::$handled); } + protected function assertJobWasFailed($class) + { + $class::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(true); + $job->shouldReceive('fail')->once(); + $job->shouldReceive('isDeleted')->andReturn(true); + $job->shouldReceive('isReleased')->twice()->andReturn(false); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(true); + $job->shouldReceive('uuid')->andReturn('simple-test-uuid'); + + $instance->call($job, [ + 'command' => serialize($command = new $class), + ]); + + $this->assertTrue($class::$handled); + } + protected function assertJobRanSuccessfully($class) { $class::$handled = false; @@ -359,6 +385,25 @@ public function middleware() } } +class CircuitBreakerFailedJob +{ + use InteractsWithQueue, Queueable; + + public static $handled = false; + + public function handle() + { + static::$handled = true; + + throw new Exception; + } + + public function middleware() + { + return [(new ThrottlesExceptions(2, 10 * 60))->failWhen(Exception::class)]; + } +} + class CircuitBreakerSuccessfulJob { use InteractsWithQueue, Queueable; From 688cb57f8dbce1b4dee94efe6dd55092db646ef0 Mon Sep 17 00:00:00 2001 From: Michael Dzjaparidze Date: Mon, 30 Jun 2025 19:05:11 +0200 Subject: [PATCH 3/3] test: adjust expectation --- tests/Integration/Queue/ThrottlesExceptionsTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/Queue/ThrottlesExceptionsTest.php b/tests/Integration/Queue/ThrottlesExceptionsTest.php index 859243b49829..34667768208b 100644 --- a/tests/Integration/Queue/ThrottlesExceptionsTest.php +++ b/tests/Integration/Queue/ThrottlesExceptionsTest.php @@ -123,7 +123,7 @@ protected function assertJobWasFailed($class) $job->shouldReceive('hasFailed')->once()->andReturn(true); $job->shouldReceive('fail')->once(); $job->shouldReceive('isDeleted')->andReturn(true); - $job->shouldReceive('isReleased')->twice()->andReturn(false); + $job->shouldReceive('isReleased')->once()->andReturn(false); $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(true); $job->shouldReceive('uuid')->andReturn('simple-test-uuid');