skip to content

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?

level: seniorimportance: should knowfreq 38%

answer

  1. releases are attempts too
  2. default --tries=1
  3. checked before handle() runs
  4. retryUntil() plus #[MaxExceptions]
  5. deadline counts from dispatch

basics

~20 s

Every 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
<?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

for a junior

Remember that a job released back to the queue still uses one of its attempts, and jobs get one attempt by default.

for a middle

Trace the attempt count through a release and explain why the worker fails the job before handle() runs on the second pickup.

for a senior

Pair a retryUntil() deadline with MaxExceptions, size the deadline from backlog and rate, and replay failures after the fix.

for a principal

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.