A Laravel job that geocodes imported property listings uses the RateLimited job middleware, and during a large import many jobs land in failed_jobs with MaxAttemptsExceededException although handle() never threw — why, and how do you fix it?
answer
- releases are attempts too
- default --tries=1
- checked before handle() runs
- retryUntil() plus #[MaxExceptions]
- deadline counts from dispatch
basics
~20 sEvery release by RateLimited consumes an attempt, and with the default of one try the next pickup exceeds the limit and fails. Give the job a retryUntil() deadline plus #[MaxExceptions] so real errors still fail fast.
solid answer
~40 s`RateLimited` runs before `handle()` and, when the named limiter is full, releases the job with a delay instead of running it. Each pickup is an attempt, and the job sets no `tries`, so the worker's `--tries` default of 1 applies: on the second pickup the worker fails the job with `MaxAttemptsExceededException` before `handle()` runs. The fix is to give the job a time budget: a `retryUntil()` deadline makes the worker ignore `tries`, `#[MaxExceptions(3)]` still fails it after three real exceptions, and `releaseAfter()` spaces the retries. Size the deadline from the backlog, because `retryUntil()` is computed at dispatch. Then deploy and replay the failures with `php artisan queue:retry all`.
code
php · 28 lines<?php
namespace App\Jobs;
use App\Models\Listing;
use DateTime;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\MaxExceptions;
use Illuminate\Queue\Middleware\RateLimited;
#[MaxExceptions(3)]
class GeocodeListing implements ShouldQueue
{
use Queueable;
public function __construct(public Listing $listing) {}
public function middleware(): array
{
return [(new RateLimited('geocoding'))->releaseAfter(30)];
}
public function retryUntil(): DateTime
{
return now()->addHours(6);
}
}go deeper
Remember that a job released back to the queue still uses one of its attempts, and jobs get one attempt by default.
Trace the attempt count through a release and explain why the worker fails the job before handle() runs on the second pickup.
Pair a retryUntil() deadline with MaxExceptions, size the deadline from backlog and rate, and replay failures after the fix.
Treat third-party quotas as capacity: plan import waves and deadlines against the provider's rate so bursts do not become failures.
## The symptom A property-listing import dispatches one `GeocodeListing` job per listing. The geocoding API allows a fixed number of calls per minute, so the job carries the built-in `Illuminate\Queue\Middleware\RateLimited` middleware with a named limiter: ```php public function middleware(): array { return [new RateLimited('geocoding')]; } ``` On a small import everything works. On a large one, hundreds of jobs appear in `failed_jobs` with `MaxAttemptsExceededException` and the message "has been attempted too many times", yet `handle()` never threw and the geocoding API never returned an error. ## Why a release fails the job `RateLimited` runs **before** `handle()`. When the limiter is exhausted it does not wait; it **releases** the job back onto the queue with a delay equal to the time until the limiter frees up (or the value given to `releaseAfter()`). The problem is the attempt count: 1. The worker picks the job up: attempt 1. The limiter is full, so the middleware releases it. 2. After the delay a worker picks it up again: attempt 2. 3. Before running anything, the worker compares attempts with the job's `tries`. The job sets none, so the worker's `--tries` default of **1** applies, and 2 exceeds it. 4. The job is failed with `MaxAttemptsExceededException` and written to `failed_jobs`. The Laravel docs warn about exactly this: releasing a rate-limited job still increments its attempts, and by default a job is attempted only once. ## Options that do not fix it | Idea | Why it falls short | |---|---| | Raise `--tries` on the worker | Changes every job on that worker, and a fixed count still runs out on a long enough backlog | | `->dontRelease()` | Rate-limited jobs are dropped without running, so listings silently stay without coordinates | | A huge `#[Tries(1000)]` alone | Also allows 1000 runs of a job that throws on every attempt | | Catching the limit inside `handle()` | Rebuilds the middleware and still releases, so attempts still climb | ## The fix: a deadline plus an exception cap Give the job a **time budget** instead of a count, and cap genuine errors separately: ```php #[MaxExceptions(3)] class GeocodeListing implements ShouldQueue { use Queueable; public function middleware(): array { return [(new RateLimited('geocoding'))->releaseAfter(30)]; } public function retryUntil(): DateTime { return now()->addHours(6); } } ``` - `retryUntil()` makes the worker ignore `tries`: releases can happen as often as needed until the deadline. - `#[MaxExceptions(3)]` fails the job after three **unhandled exceptions**, so a broken address format or an API outage does not loop for six hours. Releases by the middleware are not exceptions and do not count toward it. - `releaseAfter(30)` spaces the retries evenly instead of relying only on the limiter's reset time. ## Sizing the deadline `retryUntil()` is evaluated **at dispatch** and stored in the payload, so the clock starts when the import queues the job, not when a worker first touches it. If 30,000 listings are queued at once and the API allows 100 calls a minute, the last job cannot run for about five hours; a one-hour deadline would fail the tail of the import with the same `MaxAttemptsExceededException`. Size the deadline from **backlog divided by allowed rate**, with margin, or dispatch in smaller waves. ## What a long deadline costs A generous `retryUntil()` is not free, so keep an eye on the side effects: - **Queue depth.** Released jobs go back onto the same queue, so thousands of waiting geocoding jobs sit alongside other work. Dispatching them to a dedicated queue keeps a slow import from delaying password-reset mail. - **Churn.** Without `releaseAfter()`, each over-limit job is released for exactly the time until the limiter frees, and the whole backlog wakes at once to compete for the next window. A fixed delay spreads the pickups. - **Silent loss.** A deadline that is too short fails the tail of the import; one that is too long hides a stuck import for hours. Alert on `failed_jobs` growth for this job class either way. ## Confirming the diagnosis - `php artisan queue:failed` lists the failed jobs by ID, class and time; the `exception` column of their `failed_jobs` rows shows `MaxAttemptsExceededException` rather than an API error. - After deploying the fix, `php artisan queue:retry all` pushes them back with a fresh attempt count and a recomputed deadline.
- What happens if the job references a limiter name that was never defined with RateLimiter::for()?`RateLimited` finds no limiter and simply calls the next middleware, so the job runs **unthrottled**. Nothing warns you; the API starts rejecting calls instead. Check that the name in `new RateLimited('geocoding')` matches the definition exactly.
- Why not just set #[Tries(500)] instead of retryUntil()?A count and a backlog are unrelated: a long enough import still exhausts it, while a job that throws on every run now gets 500 attempts. A deadline states the real requirement, finishing within a time window, and `#[MaxExceptions]` separately bounds genuine failures.
saying these in an interview costs you the question
- Middleware releases do not count as attempts, so tries is irrelevant here.
- MaxAttemptsExceededException means the geocoding API returned errors.
- Calling dontRelease() is a safe fix because jobs simply wait instead.
- retryUntil() is measured from the job's first attempt, not from dispatch.
- MaxExceptions also counts the releases made by RateLimited.