Exception Hierarchies: Control Laravel Queue Retry Behavior

Table of Contents

πŸ“– 3 minutes read

Here’s a scenario every Laravel developer hits eventually: your queue job integrates with a third-party API, and that API starts returning errors. Your job dutifully retries 5 times, backs off, and eventually fails. Multiply that by thousands of jobs, and suddenly your queue is clogged with doomed retries.

The problem? Not all errors deserve retries.

The Setup

Imagine a job that syncs inventory from an external REST API:

class SyncInventoryJob implements ShouldQueue
{
    public $tries = 5;
    public $backoff = [10, 30, 60, 120, 300];

    public function handle()
    {
        try {
            $response = $this->apiClient->fetchInventory($this->productId);
            $this->processResponse($response);
        } catch (RateLimitedException $e) {
            $this->release($e->retryAfter());
        } catch (TemporarilyUnavailableException $e) {
            $this->delete();
            Log::warning("API unavailable for product {$this->productId}, removing from queue");
        } catch (\Exception $e) {
            // Generic retry β€” but should we?
            throw $e;
        }
    }
}

The catch blocks form a hierarchy: rate-limited gets a smart release, temporarily unavailable gets deleted, and everything else retries via the default mechanism.

The Bug

The API client was throwing a generic ApiException for every failure β€” including vendor-side database errors that would never self-resolve. These fell through to the generic \Exception catch, triggering 5 retries each.

With 4,000 affected products, that’s 20,000 wasted queue attempts hammering a broken endpoint.

The Fix

Map vendor-side errors to your existing exception hierarchy:

class ApiClient
{
    public function fetchInventory(string $productId): array
    {
        $response = Http::get("{$this->baseUrl}/inventory/{$productId}");

        if ($response->status() === 429) {
            throw new RateLimitedException(
                retryAfter: $response->header('Retry-After', 60)
            );
        }

        if ($response->serverError()) {
            $body = $response->body();

            // Vendor-side errors that WE can't fix
            if (str_contains($body, 'Internal Server Error')
                || str_contains($body, 'database timeout')) {
                throw new TemporarilyUnavailableException(
                    "Vendor-side error: {$response->status()}"
                );
            }
        }

        if ($response->failed()) {
            throw new ApiException("API error: {$response->status()}");
        }

        return $response->json();
    }
}

The key insight: TemporarilyUnavailableException already existed in the job’s catch hierarchy. We just needed the API client to throw it for the right situations. No job changes required.

The Pattern

Design your exception hierarchy around what the caller should do, not what went wrong:

  • RateLimitedException β†’ release with delay (their problem, temporary)
  • TemporarilyUnavailableException β†’ delete, don’t retry (their problem, not our concern)
  • InvalidConfigException β†’ fail permanently (our problem, needs code fix)
  • Generic Exception β†’ retry with backoff (unknown, worth trying)

When a new error type appears, you don’t change the job β€” you classify it into the right exception type at the source.

The takeaway: Exception hierarchies in queue jobs aren’t just about catching errors. They’re a retry policy DSL. Each exception class encodes a decision about what to do next. Make that decision at the API client level, and your jobs stay clean.

Daryle De Silva

VP of Technology

11+ years building and scaling web applications. Writing about what I learn in the trenches.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *