Skip to content
Open
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
15 changes: 15 additions & 0 deletions src/Illuminate/Contracts/Queue/ShouldntRetry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace Illuminate\Contracts\Queue;

/**
* Marks an exception that should cause a job to fail immediately.
*
* When a job throws an exception implementing this contract, the job skips any
* retries it has remaining and is failed immediately, regardless of the number
* of attempts or the "retry until" timestamp configured for the job.
*/
interface ShouldntRetry
{
//
}
22 changes: 22 additions & 0 deletions src/Illuminate/Queue/Worker.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Queue\Factory as QueueManager;
use Illuminate\Contracts\Queue\Interruptible;
use Illuminate\Contracts\Queue\ShouldntRetry;
use Illuminate\Database\DetectsLostConnections;
use Illuminate\Queue\Events\JobAttempted;
use Illuminate\Queue\Events\JobExceptionOccurred;
Expand Down Expand Up @@ -581,6 +582,12 @@ protected function handleJobException($connectionName, $job, WorkerOptions $opti
// First, we will go ahead and mark the job as failed if it will exceed the maximum
// attempts it is allowed to run the next time we process it. If so we will just
// go ahead and mark it as failed now so we do not have to release this again.
if (! $job->hasFailed()) {
$this->markJobAsFailedIfExceptionShouldntRetry(
$connectionName, $job, $e
);
}

if (! $job->hasFailed()) {
$this->markJobAsFailedIfWillExceedMaxAttempts(
$connectionName, $job, (int) $options->maxTries, $e
Expand Down Expand Up @@ -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.
*
Expand Down
80 changes: 80 additions & 0 deletions tests/Integration/Queue/ShouldntRetryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

namespace Illuminate\Tests\Integration\Queue;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldntRetry;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\DB;
use Orchestra\Testbench\Attributes\WithMigration;
use RuntimeException;

#[WithMigration]
#[WithMigration('queue')]
class ShouldntRetryTest extends QueueTestCase
{
protected function defineEnvironment($app)
{
parent::defineEnvironment($app);

$app['config']->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;
}
}
53 changes: 53 additions & 0 deletions tests/Queue/QueueWorkerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -939,3 +987,8 @@ class LoopBreakerException extends RuntimeException
{
//
}

class WorkerShouldntRetryException extends RuntimeException implements ShouldntRetry
{
//
}