Skip to content
53 changes: 53 additions & 0 deletions src/Illuminate/Queue/Middleware/RetryIf.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

namespace Illuminate\Queue\Middleware;

use Closure;
use Throwable;

class RetryIf
{
/**
* @param \Closure(\Throwable, ?mixed): bool $retryIf The condition of the failure that will retry the job.
*/
public function __construct(protected Closure $retryIf)
{
}

/**
* @param class-string<\Throwable> ...$exceptions
* @return static
*/
public static function failureIsNot(...$exceptions): static
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we have both failureIsNot and failureIs?

In a project, I want to retry only if the exception is related to the HTTP client, but not on other errors.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

I had a very similar middleware on this particular project, but your implementation is cleaner, and I already refactored it. Thanks again.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was writing one this morning for a project I'm working on 😅 Good to see it's useful for more than just me.

{
return new static(static function (Throwable $throwable) use ($exceptions) {
foreach ($exceptions as $exception) {
if ($throwable instanceof $exception) {
return false;
}
}

return true;
});
}

/**
* @param mixed $job
* @param callable $next
* @return mixed
*
* @throws Throwable
*/
public function handle($job, callable $next)
{
try {
return $next($job);
} catch (Throwable $e) {
if (call_user_func($this->retryIf, $e, $job) !== true) {
$job->fail($e);
}

throw $e;
}
}
}
95 changes: 95 additions & 0 deletions tests/Queue/RetryIfMiddlewareTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

namespace Illuminate\Tests\Integration\Queue;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\RetryIf;
use Illuminate\Support\Facades\Queue;
use InvalidArgumentException;
use LogicException;
use Orchestra\Testbench\Attributes\WithConfig;
use Orchestra\Testbench\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;

#[WithConfig('queue.default', 'database')]
class RetryIfMiddlewareTest extends TestCase
{
use DatabaseMigrations;

public static function markFailedForRetryIfDataProvider(): array
{
return [
'middleware fails on thrown exception' => [
InvalidArgumentException::class,
1,
1,
],
'middleware retries if exception does not match' => [
LogicException::class,
2,
1,
],
];
}

#[DataProvider('markFailedForRetryIfDataProvider')]
public function test_retry_if_middleware(
$throws,
int $expectedExceptions,
int $expectedFails
) {
$this->markTestSkippedWhen(config('queue.default') === 'sync', 'Does not run when sync.');

RetryIfMiddlewareJob::dispatch($throws)->onQueue('default')->onConnection('database');

$failsCalled = $exceptionsOccurred = 0;
Queue::exceptionOccurred(function () use (&$exceptionsOccurred) {
$exceptionsOccurred++;
});
Queue::failing(function () use (&$failsCalled) {
$failsCalled++;
});

for ($i = 0; $i < 2; $i++) {
$this->artisan('queue:work', [
'--memory' => 1024,
'--stop-when-empty' => true,
'--sleep' => 1,
])->assertSuccessful();
}

$this->assertEquals($expectedExceptions, $exceptionsOccurred);
$this->assertEquals($expectedFails, $failsCalled);
}
}

class RetryIfMiddlewareJob implements ShouldQueue
{
use InteractsWithQueue;
use Queueable;
use Dispatchable;

public int $tries = 2;

public function __construct(private $throws)
{
}

public function handle()
{
if ($this->throws === null) {
return; // success
}

throw new ($this->throws);
}

public function middleware(): array
{
return [RetryIf::failureIsNot(InvalidArgumentException::class)];
}
}
Loading