Table of Contents
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.
Leave a Reply