skip to content

In Laravel, when would you attach the RateLimited, WithoutOverlapping or ThrottlesExceptions job middleware, and how does each one affect a job's attempts?

level: seniorimportance: should knowfreq 30%

answer

  1. quota, mutual exclusion, failing dependency
  2. named limiter versus cache lock
  3. ThrottlesExceptions(10, 600) defaults
  4. backoff() here is minutes
  5. every release is an attempt

basics

~20 s

RateLimited enforces a known quota through a named limiter, WithoutOverlapping lets one job per key run at a time using a cache lock, and ThrottlesExceptions backs off after repeated exceptions. All three release jobs, and each release consumes an attempt.

solid answer

~40 s

Use `RateLimited('geocoding')` when you know the quota up front: it checks a limiter from `RateLimiter::for()` before `handle()` and releases the job until the limiter frees. Use `WithoutOverlapping($id)` when two jobs must not touch the same record at once: it takes a cache lock on class plus key and, by default, releases a blocked job immediately, so add `releaseAfter()` and `expireAfter()`. Use `ThrottlesExceptions(10, 600)` for a dependency that starts failing: after that many exceptions it holds further attempts back for the decay window, and its `backoff()` is in minutes. Every release is an attempt, so raise `tries` or use `retryUntil()`; with `ThrottlesExceptions` a deadline matters most, because it catches the exceptions itself and `#[MaxExceptions]` never sees them.

code

php · 30 lines
php
<?php

use App\Models\Listing;
use DateTime;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
use Illuminate\Queue\Middleware\WithoutOverlapping;

class GeocodeListing implements ShouldQueue
{
    use Queueable;

    public function __construct(public Listing $listing) {}

    public function middleware(): array
    {
        return [
            new RateLimited('geocoding'),
            (new WithoutOverlapping($this->listing->id))->releaseAfter(60)->expireAfter(180),
            (new ThrottlesExceptions(5, 10 * 60))->by('geocoder')->backoff(1),
        ];
    }

    public function retryUntil(): DateTime
    {
        return now()->addHours(6);
    }
}

go deeper

for a junior

Know that job middleware go in a middleware() method and that Laravel has built-in ones for rate limits and overlaps.

for a middle

Match each middleware to its problem, name its main options, and explain that every release uses an attempt.

for a senior

Configure lock expiry, shared throttling keys and deadlines so an outage or crashed worker does not stall or fail a whole import.

for a principal

Decide where protection belongs: per-job middleware, a shared client wrapper, or queue-level isolation for a fragile provider.

## Three built-in job middleware Laravel ships job middleware in `Illuminate\Queue\Middleware`, attached by returning instances from a job's `middleware()` method. Three of them decide **whether a job runs now or goes back to the queue**, and each solves a different problem. | Middleware | Problem it solves | Acts | Default when blocked | Backed by | |---|---|---|---|---| | `RateLimited('name')` | A known quota, such as 100 calls a minute | Before `handle()` | Release until the limiter frees | A named rate limiter | | `WithoutOverlapping($key)` | Two jobs must not touch the same resource at once | Around `handle()` | Release immediately (`releaseAfter` 0) | A cache lock | | `ThrottlesExceptions($max, $seconds)` | A dependency that starts failing | Around `handle()`, counting its exceptions | Release after the decay window | A cache counter | ## RateLimited: a quota you know in advance `new RateLimited('geocoding')` looks up a limiter registered with `RateLimiter::for()` and checks it **before** the job runs. Within the limit it records a hit and runs the job; over it, it releases the job for the time until the limiter frees up. - `releaseAfter(30)` sets a fixed release delay instead. - `dontRelease()` drops over-limit jobs instead of requeueing them. - If the limiter name is not defined, the job runs unthrottled. - `RateLimitedWithRedis` is the variant tuned for Redis. ## WithoutOverlapping: one at a time per key `new WithoutOverlapping($this->listing->id)` takes an atomic **cache lock** on the job class plus the key and runs the job only if it gets the lock, releasing it afterwards. A job that finds the lock taken is released straight back by default, which burns attempts quickly, so: - `releaseAfter(60)` waits before the retry; - `expireAfter(180)` gives the lock a lifetime, so a worker that dies mid-job cannot hold it forever; - `dontRelease()` deletes overlapping jobs instead of retrying them; - `shared()` makes different job classes that use the same key exclude one another. It needs a cache store that supports locks. ## ThrottlesExceptions: back off from a failing dependency `new ThrottlesExceptions(10, 5 * 60)` (the defaults are 10 and 600) wraps `handle()` in a `try`/`catch`. Each exception adds a hit to a counter, and a successful run clears it. Once the counter reaches the maximum, further attempts are released until the decay period passes, which gives an unstable API room to recover. - `backoff(5)` delays retries **in minutes** before the threshold is reached; it also accepts a closure receiving the exception. - The key defaults to the **job class**, so every instance of the class shares one bucket; `by('geocoder')` shares it across classes, `byJob()` narrows it to a single job. - `when(...)` limits throttling to some exceptions, `deleteWhen(...)` and `failWhen(...)` delete or fail on others, and `report(...)` still reports them. ## How each one uses attempts All three **release** the job, and every release makes the next pickup an attempt. Two consequences: 1. With the default of one try, the first release is fatal: the job fails with `MaxAttemptsExceededException` on its next pickup. 2. `ThrottlesExceptions` catches the exception and releases the job itself, so the worker never sees it (unless a `when()` filter rethrows it). The job's `backoff` is not applied and `#[MaxExceptions]` is not advanced by those exceptions. That is why the Laravel docs pair `ThrottlesExceptions` with a `retryUntil()` deadline, and advise tuning `tries` (or, for `RateLimited`, using a deadline) for the other two. ## Choosing quickly 1. **Is there a published limit?** Use `RateLimited`; the quota is enforced before any call is made. 2. **Would two runs at once corrupt data?** Use `WithoutOverlapping` keyed by the record, with `releaseAfter()` and `expireAfter()`. 3. **Does the dependency fail in bursts?** Use `ThrottlesExceptions`, keyed with `by()` when several job classes call it, and give the job a `retryUntil()` deadline. None of the three makes a job idempotent or stops duplicates from being dispatched; they only decide when an already queued job may run. ## Combining them The middleware run in the order returned. For the geocoding job in a listing import you might return `[new RateLimited('geocoding'), new ThrottlesExceptions(5, 600)]` with a `retryUntil()` deadline: the quota is respected up front, and an outage pauses the whole class of jobs rather than failing each one.

  • Why does WithoutOverlapping need expireAfter() in production?
    The lock is released in a `finally` block after the job runs. If the worker process is killed mid-job, for example by a timeout or an out-of-memory kill, that block never runs, and without an expiry the lock can outlive the job and block that key. `expireAfter(180)` gives the lock a lifetime.
  • How do you make two different job classes that call the same API share one ThrottlesExceptions bucket?
    Call `by('geocoder')` on the middleware in both classes. By default the key is derived from the job class, so each class counts its own exceptions; a shared key makes an outage seen by one class pause the other as well.

A busy clinic: RateLimited is the appointment book that only takes so many patients an hour, WithoutOverlapping is the single key to the X-ray room, and ThrottlesExceptions is the receptionist who, after several failed calls to the lab, stops calling for ten minutes. Anyone turned away rejoins the queue and uses up one of their visits.

saying these in an interview costs you the question

  • ThrottlesExceptions' backoff() takes seconds, like the job's $backoff.
  • WithoutOverlapping makes the second job wait on the lock until it is free.
  • ThrottlesExceptions counts exceptions separately for every job instance by default.
  • Exceptions caught by ThrottlesExceptions still count toward #[MaxExceptions].
  • Middleware releases are free and do not use up any of the job's tries.