Updated June 2026. Tested on Laravel 13 and PHP 8.4. Part of the Techalyst queue series.

If your jobs call a third party API, sooner or later you will hit its rate limit and start getting 429 Too Many Requests. Hammering the API harder will not help. The right move is to back off and try again once the limit resets, without blocking every other job behind it.

Step 1: react to a 429 by releasing

The simplest version catches the 429 and releases the job to try again shortly.

public $tries = 10;

public function handle(): void
{
    $response = Http::acceptJson()->withToken('...')->timeout(10)->get('https://api.example.com/...');

    if ($response->failed() && $response->status() === 429) {
        return $this->release(30); // try again in 30 seconds
    }

    // ... use the response
}

This works, but it is wasteful. Once you hit the limit, every other job that calls the same API in the next window will also fire a doomed request and release. You are spending requests you know will fail.

Step 2: honour Retry-After and skip doomed requests

A 429 usually comes with a Retry-After header telling you exactly how many seconds until requests are allowed again. Store that moment in the cache, and have every job check it before making a request, so nobody sends a request that is bound to fail.

public function handle(): void
{
    // If we are still inside a known cooldown, release without calling the API.
    if ($timestamp = Cache::get('api-limit')) {
        return $this->release($timestamp - time());
    }

    $response = Http::acceptJson()->withToken('...')->timeout(10)->get('https://api.example.com/...');

    if ($response->failed() && $response->status() === 429) {
        $seconds = (int) $response->header('Retry-After');

        Cache::put('api-limit', now()->addSeconds($seconds)->timestamp, $seconds);

        return $this->release($seconds);
    }

    // ... use the response
}

Now the first job to hit the limit records the cooldown, and every other job releases itself immediately without wasting a request, until the window clears. Treat the Retry-After value as untrusted input from an external service and cast or validate it before use.

Step 3: expire by time, not a fixed tries count

Because the job may be throttled an unpredictable number of times, a static $tries is brittle. Drop the attempt limit and expire by wall clock time instead.

public $tries = 0;

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

But with no attempt limit, a genuine bug could retry for the full twelve hours. Cap real failures with maxExceptions so the job gives up quickly if something is actually broken, while still allowing unlimited throttle releases.

public $tries = 0;
public $maxExceptions = 3;

The modern shortcut: job middleware

Laravel ships job middleware that handles much of this for you, so you do not have to hand roll the cache logic. Two are especially useful here.

ThrottlesExceptions backs a job off after it throws too many times in a window, ideal for a flaky or rate limited API.

use Illuminate\Queue\Middleware\ThrottlesExceptions;

public function middleware(): array
{
    // allow 10 exceptions per 5 minutes, then back off
    return [(new ThrottlesExceptions(10, 5 * 60))->backoff(5)];
}

RateLimited ties a job to a named rate limiter, so you cap how many of these jobs run per minute regardless of the API's own response.

use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Cache\RateLimiting\Limit;

// in a service provider
RateLimiter::for('external-api', fn () => Limit::perMinute(30));
use Illuminate\Queue\Middleware\RateLimited;

public function middleware(): array
{
    return [new RateLimited('external-api')];
}

Pair either with tries = 0 and retryUntil, and you get clean, framework supported throttling without manual cache bookkeeping.

Wrapping up

To survive an API rate limit in a queued job: release on a 429, but go further and honour Retry-After through a shared cache key so other jobs do not waste doomed requests. Expire the job by time with retryUntil rather than a fixed $tries, and guard real bugs with maxExceptions. For most cases, reach for the built in ThrottlesExceptions or RateLimited job middleware and let the framework do the bookkeeping.

More in the series: designing reliable, self-contained jobs. Questions welcome below.