Skip to content

[13.x] An exception can implement ShouldntRetry to prevent retries of jobs#60504

Open
alexbowers wants to merge 1 commit into
laravel:13.xfrom
alexbowers:shouldnt-retry-exception-contract
Open

[13.x] An exception can implement ShouldntRetry to prevent retries of jobs#60504
alexbowers wants to merge 1 commit into
laravel:13.xfrom
alexbowers:shouldnt-retry-exception-contract

Conversation

@alexbowers

Copy link
Copy Markdown
Contributor

Some jobs when processing are useful to be retried a few times, for example, if you hit a rate limit or a temporary network problem, however, within those jobs there can also be times where the system is not recoverable at all.

Currently, you have to call $this->fail() on these jobs, and it adds a layer of boilerplate that you have to remember for each job you're writing, since it's controlled within the job itself.

However, some exceptions are always unrecoverable, for example, in stripe a PaymentDeclined cannot be recovered, and retrying is completely pointless.

Adding ShouldntRetry on that exception will now make it so that that specific exception flags the job as failed, without the manual boilerplate on each job.

  use Illuminate\Contracts\Queue\ShouldntRetry;

  class PaymentDeclinedException extends RuntimeException implements ShouldntRetry
  {
  }

  class ChargeCustomer implements ShouldQueue
  {
      use Dispatchable, InteractsWithQueue, Queueable;

      public int $tries = 5;

      public function handle(PaymentGateway $gateway): void
      {
          // A declined card throws PaymentDeclinedException → fails immediately.
          // A gateway timeout throws something else → retried up to 5 times.
          $gateway->charge($this->customerId, $this->amount);
      }
  }

To get the same behaviour today, the code looks something like this:

 class ChargeCustomer implements ShouldQueue
  {
      use Dispatchable, InteractsWithQueue, Queueable;

      public int $tries = 5;

      public function handle(PaymentGateway $gateway): void
      {
          try {
              $gateway->charge($this->customerId, $this->amount);
          } catch (PaymentDeclinedException $e) {
              $this->fail($e);

              return;
          }
      }
  }

However, this setup gets worse if you have the $gateway->charge() inside of a method you want to return a value for.

Since you cannot just throw an exception without getting the retries, you have to either catch the exception like this, or you can do a $this->fail() inside that method, but then the return type has to be nullable so that you can return early and handle the response.

@gutentagbomb

Copy link
Copy Markdown

This makes it more complicated in my opinion

@alexbowers

alexbowers commented Jun 13, 2026 via email

Copy link
Copy Markdown
Contributor Author

@alexbowers

Copy link
Copy Markdown
Contributor Author

Where the benefit for this comes in is really when you have a job that extracts its logic into its own methods. Take for example:

<?php

class BookFlight implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public int $tries = 5;

    public function __construct(
        public Flight $flight,
        public Customer $customer,
        public array $seats,
        public float $amount
    ) {
    }

    public function handle(PaymentGateway $gateway): void
    {
        $reservation = $this->reserve($this->flight);

        $charge = $this->charge($reservation, $this->amount);

        $invoice = $this->invoice($charge);

        $this->book($reservation, $invoice);
    }

    private function reserve(Flight $flight, Customer $customer, array $seats): Reservation
    {
        if (! $flight) {
            throw new FlightNotFoundException();
            // ^^ No point retrying
        }

        if ($customer->hasAlreadyReserved($flight, $seats)) {
            return $customer->getReservation($flight, $seats);
        }

        if (!$flight->isSeatAvailable($this->seats)) {
            throw new SeatNotAvailableException();
            // ^^ No point retrying
        }

        if ($customer->isAllowedSeat($flight, $this->seats)) {
            throw new CustomerNotAllowedSeatException();
            // ^^ No point retrying
        }

        return $flight->reserve($this->customerId, $this->seats);
    }

    private function charge(Reservation $reservation, float $amount): Charge
    {
        $gateway = app(PaymentGateway::class);

        return $gateway->charge($reservation->customer, $this->amount);
        // ^^ This can throw for example: 
        // - PaymentGatewayException (retryable)
        // - PaymentTimeoutException (retryable)
        // - PaymentNetworkException (retryable)
        // - PaymentUnknownException (retryable)
        // - PaymentDeclinedException (not retryable)
        // - PaymentFraudException (not retryable)
        // - PaymentInvalidException (not retryable)
    }

    private function invoice(Charge $charge): Invoice
    {
        $invoice = app(InvoiceService::class)->create($charge);
        // ^^ This can throw for example:
        // - InvoiceInvalidException (retryable)
        // - NetworkTimeoutException (retryable)
        // - InvoiceAlreadyExistsException (not retryable)
        // - InvalidChargeException (not retryable)

        $invoice->send();
        // ^^ This can throw for example:
        // - NetworkTimeoutException (retryable)
        // - InvalidEmailException (not retryable)

        return $invoice;
    }
}

To do this without unnecessary retries on the ones that are not worth it, it would look something like this:

<?php

class BookFlight implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public int $tries = 5;

    public function __construct(
        public Flight $flight,
        public Customer $customer,
        public array $seats,
        public float $amount
    ) {
    }

    public function handle(PaymentGateway $gateway): void
    {
        $reservation = $this->reserve($this->flight);

        if (! $reservation) {
            return;
        }

        $charge = $this->charge($reservation, $this->amount);

        if (! $charge) {
            return;
        }

        $invoice = $this->invoice($charge);

        if (! $invoice) {
            return;
        }

        $this->book($reservation, $invoice);
    }

    private function reserve(Flight $flight, Customer $customer, array $seats): ?Reservation
    {
        if (! $flight) {
            $this->fail(new FlightNotFoundException());

            report($e);

            return null;
        }

        if ($customer->hasAlreadyReserved($flight, $seats)) {
            return $customer->getReservation($flight, $seats);
        }

        if (!$flight->isSeatAvailable($this->seats)) {
            $this->fail(new SeatNotAvailableException());

            report($e);

            return null;
        }

        if ($customer->isAllowedSeat($flight, $this->seats)) {
            $this->fail(new CustomerNotAllowedSeatException());

            report($e);

            return null;
        }

        return $flight->reserve($this->customerId, $this->seats);
    }

    private function charge(Reservation $reservation, float $amount): ?Charge
    {
        $gateway = app(PaymentGateway::class);

        try {
            return $gateway->charge($reservation->customer, $this->amount);
        } catch (PaymentException $e) {
            if ($e instanceof PaymentDeclinedException || $e instanceof PaymentFraudException || $e instanceof PaymentInvalidException) {
                $this->fail($e);

                report($e);

                return null;
            }

            throw $e;
        }
    }

    private function invoice(Charge $charge): ?Invoice
    {
        try {
            $invoice = app(InvoiceService::class)->create($charge);

            $invoice->send();
        } catch (InvoiceException $e) {
            if ($e instanceof InvoiceAlreadyExistsException || $e instanceof InvalidChargeException || $e instanceof InvalidEmailException) {
                $this->fail($e);

                report($e);

                return null;
            }

            throw $e;
        }

        return $invoice;
    }
}

This approach has various downsides:

  1. It's very easy to forget to report() the exception, so you may end up missing critical exceptions
  2. The logic needs to be duplicated across potentially multiple jobs, for the same exceptions, and it could be easy to miss some meaning unnecessary jobs being ran
  3. The return types of your jobs now require to be nullable which isn't actually an accurate description of what it should be returning
  4. You lose any benefits of the rest of the Laravel exception handling, which can be done at a higher level in the base bootstrap withExceptions methods.

With this change, the original example would just work as you'd expect it to, no unnecessary retries, no change of it being missed anywhere because its defined once on the exception instead of per-job, and it falls down to using the default exception handler if necessary too.

@taylorotwell

Copy link
Copy Markdown
Member

How would you add this interface to vendor generated exceptions that are unrecoverable, like the Stripe example in your original description?

@alexbowers

Copy link
Copy Markdown
Contributor Author

My plan would be to instead capture the vendored ones inside of my service code itself, and re-bubble it up with my own exception that extends theirs.

Something along the lines of:

class MyNonRetryableException extends ExampleVendoredException implements ShouldntRetry
{
    
}

You're right that this wouldn't help with the fully vendored ones that bubble up through, although I am not sure there is anything we can do about that, since there is no form of monkeypatching in PHP that would allow us to inject an interface onto a class at runtime right?

@alexbowers

Copy link
Copy Markdown
Contributor Author

An alternative suggestion to handle this could be something like the withExceptions handler on the main bootstrap process, where a new method for retry() is added alongside the render and report that is used to determine whether the job is retryable (based on a truthy response).

That's an alternative implementation in general though, but if you favour that instead, I can take a look at building it and seeing what it would look like.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants