From dfc1894e0ad1de45f44229705c37b5a0c9df8e33 Mon Sep 17 00:00:00 2001 From: Alex Bowers Date: Sat, 13 Jun 2026 08:52:13 +0100 Subject: [PATCH] An exception can implement ShouldntRetry to prevent retries of jobs --- .../Contracts/Queue/ShouldntRetry.php | 15 ++++ src/Illuminate/Queue/Worker.php | 22 +++++ tests/Integration/Queue/ShouldntRetryTest.php | 80 +++++++++++++++++++ tests/Queue/QueueWorkerTest.php | 53 ++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 src/Illuminate/Contracts/Queue/ShouldntRetry.php create mode 100644 tests/Integration/Queue/ShouldntRetryTest.php diff --git a/src/Illuminate/Contracts/Queue/ShouldntRetry.php b/src/Illuminate/Contracts/Queue/ShouldntRetry.php new file mode 100644 index 000000000000..b914e8e5dc76 --- /dev/null +++ b/src/Illuminate/Contracts/Queue/ShouldntRetry.php @@ -0,0 +1,15 @@ +hasFailed()) { + $this->markJobAsFailedIfExceptionShouldntRetry( + $connectionName, $job, $e + ); + } + if (! $job->hasFailed()) { $this->markJobAsFailedIfWillExceedMaxAttempts( $connectionName, $job, (int) $options->maxTries, $e @@ -691,6 +698,21 @@ protected function markJobAsFailedIfWillExceedMaxExceptions($connectionName, $jo } } + /** + * Mark the given job as failed if the thrown exception should skip remaining retries. + * + * @param string $connectionName + * @param \Illuminate\Contracts\Queue\Job $job + * @param \Throwable $e + * @return void + */ + protected function markJobAsFailedIfExceptionShouldntRetry($connectionName, $job, Throwable $e) + { + if ($e instanceof ShouldntRetry) { + $this->failJob($job, $e); + } + } + /** * Mark the given job as failed if it should fail on timeouts. * diff --git a/tests/Integration/Queue/ShouldntRetryTest.php b/tests/Integration/Queue/ShouldntRetryTest.php new file mode 100644 index 000000000000..6be234a9cf7a --- /dev/null +++ b/tests/Integration/Queue/ShouldntRetryTest.php @@ -0,0 +1,80 @@ +set('queue.default', 'database'); + $this->driver = 'database'; + } + + #[\Override] + protected function tearDown(): void + { + ShouldntRetryTestJob::$attempts = 0; + + parent::tearDown(); + } + + public function testJobFailsImmediatelyWhenItThrowsAnExceptionThatShouldntRetry() + { + ShouldntRetryTestJob::$exception = new ShouldntRetryTestException; + + ShouldntRetryTestJob::dispatch(); + + $this->runQueueWorkerCommand(['--once' => true, '--tries' => 3]); + + $this->assertSame(1, ShouldntRetryTestJob::$attempts); + $this->assertNull(DB::table('jobs')->first()); + $this->assertNotNull(DB::table('failed_jobs')->first()); + } + + public function testJobIsRetriedWhenItThrowsAnExceptionThatDoesNotOptOutOfRetries() + { + ShouldntRetryTestJob::$exception = new RuntimeException('Transient failure.'); + + ShouldntRetryTestJob::dispatch(); + + $this->runQueueWorkerCommand(['--once' => true, '--tries' => 3]); + + $this->assertSame(1, ShouldntRetryTestJob::$attempts); + $this->assertNotNull(DB::table('jobs')->first()); + $this->assertNull(DB::table('failed_jobs')->first()); + } +} + +class ShouldntRetryTestException extends RuntimeException implements ShouldntRetry +{ + // +} + +class ShouldntRetryTestJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable; + + public static int $attempts = 0; + + public static ?\Throwable $exception = null; + + public function handle() + { + static::$attempts++; + + throw static::$exception; + } +} diff --git a/tests/Queue/QueueWorkerTest.php b/tests/Queue/QueueWorkerTest.php index 3cf7e7f1170d..f5e5eb2f1376 100755 --- a/tests/Queue/QueueWorkerTest.php +++ b/tests/Queue/QueueWorkerTest.php @@ -8,6 +8,7 @@ use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Queue\Interruptible; use Illuminate\Contracts\Queue\Job as QueueJobContract; +use Illuminate\Contracts\Queue\ShouldntRetry; use Illuminate\Queue\CallQueuedHandler; use Illuminate\Queue\Events\JobExceptionOccurred; use Illuminate\Queue\Events\JobPopped; @@ -248,6 +249,53 @@ public function testJobIsReleasedOnException() $this->events->shouldNotHaveReceived('dispatch', [m::type(JobProcessed::class)]); } + public function testJobIsFailedWithoutRetryingWhenExceptionShouldntRetry() + { + $e = new WorkerShouldntRetryException; + + $job = new WorkerFakeJob(function ($job) use ($e) { + $job->attempts++; + + throw $e; + }); + + $job->attempts = 0; + + $worker = $this->getWorker('default', ['queue' => [$job]]); + $worker->runNextJob('default', 'queue', $this->workerOptions(['maxTries' => 3, 'backoff' => 10])); + + $this->assertNull($job->releaseAfter); + $this->assertFalse($job->released); + $this->assertTrue($job->deleted); + $this->assertEquals($e, $job->failedWith); + $this->exceptionHandler->shouldHaveReceived('report')->with($e); + $this->events->shouldHaveReceived('dispatch')->with(m::type(JobExceptionOccurred::class))->once(); + $this->events->shouldNotHaveReceived('dispatch', [m::type(JobProcessed::class)]); + } + + public function testJobIsReleasedWhenExceptionDoesNotImplementShouldntRetry() + { + $e = new RuntimeException; + + $job = new WorkerFakeJob(function ($job) use ($e) { + $job->attempts++; + + throw $e; + }); + + $job->attempts = 0; + + $worker = $this->getWorker('default', ['queue' => [$job]]); + $worker->runNextJob('default', 'queue', $this->workerOptions(['maxTries' => 3, 'backoff' => 10])); + + $this->assertEquals(10, $job->releaseAfter); + $this->assertTrue($job->released); + $this->assertFalse($job->deleted); + $this->assertNull($job->failedWith); + $this->exceptionHandler->shouldHaveReceived('report')->with($e); + $this->events->shouldHaveReceived('dispatch')->with(m::type(JobExceptionOccurred::class))->once(); + } + public function testExceptionIsNotReportedIfReportJobExceptionsIsDisabled() { $e = new RuntimeException; @@ -939,3 +987,8 @@ class LoopBreakerException extends RuntimeException { // } + +class WorkerShouldntRetryException extends RuntimeException implements ShouldntRetry +{ + // +}