The action dispatched the job and returned. A payment provider times out. The job should try again, with a pause, and then stop. An instant retry hammers the provider. An infinite retry hides a bug.
Queues in production · Lesson 2
Retries, backoff and timeouts
Video
01 Wait longer between attempts
$tries caps the attempts. backoff() is the delay before each retry, in seconds. $timeout is how long handle() may run.
class MarkOrderPaid implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public int $timeout = 60;
public function backoff(): array
{
return [15, 60, 180];
}
}The queue connection’s retry_after must be longer than $timeout. If Redis retries the job after 60 seconds while handle() is still running, a second worker starts the same order. Set retry_after to 90 when the timeout is 60.
Throw when the provider fails and you want a retry. Return when the order is already paid: that is success, not a failure, and it is the concern of Make the job safe to run twice, later in this series.
02 A decline is not a timeout
A timeout means you do not know whether the card was charged. Throw, and let backoff() wait. A decline is an answer. Record it and return, or the same card is offered on every remaining try.
class Example
{
public function handle(PaymentGateway $gateway): void
{
$order = Order::query()->findOrFail($this->orderId);
$result = $gateway->charge($order);
if ($result->timedOut()) {
throw new PaymentTimeout($order->id);
}
if ($result->declined()) {
$order->forceFill(['status' => 'failed'])->save();
return;
}
$order->forceFill(['status' => 'paid'])->save();
}
}The throw uses one of the five tries. The return does not. Laravel deletes the job after a normal return, and the failed status is the record a person can see. Do not throw for a decline unless you want the provider called again.
backoff() returns three delays and the job allows five tries. After the list runs out, Laravel keeps using the last delay, 180 seconds, for the tries that remain. You do not need a number for every try. You do need retry_after on the Redis connection to stay above the 60 second timeout, or a slow charge is started twice.
Recap
You can retry a timeout with a pause, and record a decline without burning the rest of the tries.
$triescaps attempts.backoff()is the wait.$timeoutis how longhandle()may run.retry_afteron the connection must be longer than$timeout.- Throw when you do not know if the card was charged. Return when the provider declined.
Check yourself · 3 questions
Question 1 of 3handle() is still running at 60 seconds and retry_after is 60. What can start?