Handling 429 Too Many Requests in Laravel Queued Jobs
· 13 min read · Boring Observability
Verified against Laravel 13.37 · Guzzle 8.2
A vendor API answers 429 Too Many Requests and puts how long to stay away in a Retry-After
header. Laravel's HTTP client doesn't read that header, Http::retry() doesn't wait for it, and a queued
job that releases itself on every 429 runs out of tries well before the vendor's window resets. Meanwhile the other few
hundred jobs for the same API keep calling it, and each one gets its own 429.
This post covers reading Retry-After in the forms vendors actually send, releasing a job for that long
without spending its retry budget, and a job middleware that makes one 429 pause every job for that API. It ends with
tests for each piece.
Key takeaways#
- A 429 doesn't throw on its own.
Http::get()returns the response. Onlythrow()turns it into aRequestException, and then the job's$backoffdecides the wait, whatever the header said. Http::retry(3)sends three requests in the same instant. Its default sleep is zero, and without awhencallback it retries a 404 as readily as a 429.release()spends an attempt. With$tries = 3, the job fails with "has been attempted too many times" after its third 429.retryUntil()with$maxExceptionsgives throttling a deadline and real errors a count.- One 429 should pause every job for that API. A job middleware that keeps the vendor's retry time in the cache releases the rest without a request.
ThrottlesExceptions::backoff()takes minutes.backoff(30)waits half an hour.
What Laravel does with a 429#
The HTTP client returns a 429 like any other response:
$response = Http::get('https://api.vendor.com/contacts');
$response->tooManyRequests(); // true
$response->failed(); // true
$response->header('Retry-After'); // "30"
Nothing is thrown, so a job that never checks the status finishes as if the call had worked. Call
throw() on the response (or on the pending request) and you get a RequestException with the
start of the body in its message:
Illuminate\Http\Client\RequestException: HTTP request returned status code 429:
{"message":"Too many requests"}
Thrown out of handle(), that is an ordinary job exception. The attempt counts against
$tries and the retry waits for the job's $backoff, which knows nothing about the header. A
job with $backoff = [10, 60] is back in ten seconds after a 429 that asked for an hour, and gets
another 429.
Reading Retry-After#
RFC 9110
allows two forms: a whole number of seconds, or an HTTP date such as Wed, 21 Oct 2026 07:28:00 GMT.
Vendors stretch that in both directions. Here is what the rate-limit docs of some common APIs said in October 2026:
| API | Throttled with | Retry-After |
Other headers |
|---|---|---|---|
| GitHub REST | 403 or 429 | Sometimes, in seconds | x-ratelimit-remaining, and x-ratelimit-reset as a Unix timestamp |
| Shopify Admin REST | 429 | Decimal seconds, 2.0 |
X-Shopify-Shop-Api-Call-Limit: 32/40 |
| Slack Web API | 429 | Whole seconds | None documented |
| OpenAI | 429 | Seconds, when present | x-ratelimit-reset-requests as a duration such as 6m0s |
| Stripe | 429 | Not documented | Stripe-Rate-Limited-Reason. A 429 without it wasn't a rate limit |
| HubSpot | 429 | Not documented | X-HubSpot-RateLimit-Remaining, X-HubSpot-RateLimit-Interval-Milliseconds |
| Twilio | 429 | Not documented | Twilio-Concurrent-Requests |
So a parser has to accept a decimal, a date or nothing at all, and needs a default for the APIs that never send the header. A small class covers it:
namespace App\Http;
use Illuminate\Http\Client\Response;
final class RetryAfter
{
public static function seconds(Response $response, int $default = 60): int
{
$value = $response->header('Retry-After');
if (is_numeric($value)) {
return max(0, (int) ceil((float) $value));
}
if ($value !== '' && ($at = strtotime($value)) !== false) {
return max(0, $at - now()->getTimestamp());
}
return $default;
}
}
header() returns an empty string for a missing header, so that case falls through to the default along
with anything unparseable. Using now() rather than time() lets tests move the clock with
travel(). What it returns:
Retry-After |
RetryAfter::seconds() |
|---|---|
30 | 30 |
2.0 | 2 |
0 | 0 |
| An HTTP date 45 seconds ahead | 45 |
| An HTTP date in the past | 0 |
soon | 60 |
| No header | 60 |
The date form relies on your clock agreeing with the vendor's, so a server running a minute slow waits a minute
longer than asked. GitHub needs one more rule: it can throttle with a 403, which tooManyRequests() does
not match. Treat a 403 with x-ratelimit-remaining: 0 as a 429, and when there is no
Retry-After, wait until x-ratelimit-reset. The IETF's
RateLimit
header draft standardises a remaining count and a window in seconds, and says Retry-After wins when
both are sent.
Why Http::retry() is the wrong place to wait#
Http::retry(3) looks like the answer and makes things worse. Its sleep argument defaults to zero, so
against an API returning 429 it sends three requests back to back, never sleeps, and then throws
HTTP request returned status code 429. Without a when callback it retries every failed
response, so a 404 or a 422 is also sent three times.
Both arguments take closures, and the sleep closure gets the exception, so it can read the header:
use App\Http\RetryAfter;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
$response = Http::retry(
3,
fn (int $attempt, Throwable $e) => $e instanceof RequestException && $e->response->tooManyRequests()
? RetryAfter::seconds($e->response) * 1000
: $attempt * 500,
fn (Throwable $e) => $e instanceof ConnectionException
|| $e->response->serverError()
|| ($e->response->tooManyRequests() && RetryAfter::seconds($e->response) <= 5),
)->get('https://api.vendor.com/contacts');
A 429 with Retry-After: 2 followed by a 503 sleeps two seconds, then one, and the third request
succeeds. The <= 5 in the when callback is the part that matters in a queued job. The
sleep happens inside the worker, which runs nothing else meanwhile, and it counts toward the job's
timeout: a 120-second Retry-After on a job with a 60-second timeout gets the worker killed
mid-sleep. Waiting a few seconds in place is fine. Anything longer should throw and let the queue hold the job, which
is what timeout and retry_after are sized
for.
Releasing the job for as long as the API asked#
release() puts the job back on the queue with a delay you choose, which is the queue's version of
sleeping without holding a worker:
use App\Http\RetryAfter;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;
class SyncContact implements ShouldQueue
{
use Queueable;
public $tries = 3;
public function __construct(public Contact $contact) {}
public function handle(): void
{
$response = Http::withToken(config('services.vendor.token'))
->put("https://api.vendor.com/contacts/{$this->contact->vendor_id}", $this->contact->toVendor());
if ($response->tooManyRequests()) {
$this->release(RetryAfter::seconds($response));
return;
}
$response->throw();
}
}
The delay is exactly what you pass. release() ignores $backoff, so a job with
$backoff = [10, 60] released for 30 seconds comes back in 30, while a 500 thrown from the same job waits
the ten. release() also accepts a DateTimeInterface, if you would rather pass the vendor's
reset time than a count of seconds.
What it doesn't do is leave the attempt count alone. Every release is an attempt, the same as an exception. With
$tries = 3 and a vendor that keeps answering 429, the job is released three times, and on the fourth
pickup the worker fails it before handle() runs:
Illuminate\Queue\MaxAttemptsExceededException: App\Jobs\SyncContact has been attempted too many times.
The job never got a real error. It spent its retry budget waiting, which is the same trap the rate-limiting middleware falls into. Give throttling a deadline instead of a count, and give real errors their own count:
public $maxExceptions = 3;
public function retryUntil(): DateTime
{
return now()->plus(hours: 6);
}
With retryUntil() defined, the worker ignores $tries and checks the deadline instead.
$maxExceptions counts only exceptions that escape handle(), so releases don't touch it. A
job that is released five times for 429s and then throws twice on 500s, with $maxExceptions = 2, fails
on the second 500 with that 500's RequestException in failed_jobs. If the deadline passes
while the job sits released, the next pickup fails it with the same "attempted too many times" message, without a
request. Pick a deadline longer than the vendor's longest window, which for a daily quota means a day.
One 429 should pause every job for that API#
The job above handles its own 429 correctly and ignores everyone else's. Queue two thousand
SyncContact jobs on twenty workers, and when the first one is throttled the other nineteen workers are
already picking up the next jobs. Each calls the API, gets its own 429 and releases itself. Every job spends a
request and an attempt finding out what the first one already knew.
The fix is to remember the 429 somewhere every worker can see it, keyed by the API rather than by the job. A job middleware does both halves, checking before the call and recording after it:
namespace App\Jobs\Middleware;
use App\Http\RetryAfter;
use Closure;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Cache;
class RespectRetryAfter
{
public function __construct(private string $api) {}
public function handle(object $job, Closure $next): void
{
$key = "retry-after:{$this->api}";
if (($until = Cache::get($key)) && $until > now()->getTimestamp()) {
$job->release($until - now()->getTimestamp() + random_int(1, 10));
return;
}
try {
$next($job);
} catch (RequestException $e) {
if (! $e->response->tooManyRequests()) {
throw $e;
}
$wait = RetryAfter::seconds($e->response);
Cache::put($key, now()->getTimestamp() + $wait, $wait);
$job->release($wait + random_int(1, 10));
}
}
}
The job gets simpler, since it only has to throw:
public function middleware(): array
{
return [new RespectRetryAfter('vendor')];
}
public function handle(): void
{
Http::withToken(config('services.vendor.token'))
->put("https://api.vendor.com/contacts/{$this->contact->vendor_id}", $this->contact->toVendor())
->throw();
}
Three of these jobs on the queue, against an API that answers the first call with Retry-After: 30, make
one request between them. All three are released for 32 to 38 seconds, and after that all three succeed.
A few details carry the weight:
- The cache has to be shared. Redis or the database store, the same one every worker on every server reads. The file or array store gives each process its own idea of whether the API is paused.
- The key names the API, not the job. Every job class that calls this vendor passes the same name and shares the pause. Use one key per credential if the vendor limits per token.
- The random seconds are jitter. Without them every paused job comes back in the same second and they all call at once, the way released jobs collide again as a group.
- Exceptions other than a 429 pass through, so
$backoff,$maxExceptionsandfailed()handle them as before.
For GitHub, widen the check to a 403 with X-RateLimit-Remaining: 0
($e->response->forbidden() is true there and tooManyRequests() is false), and fall back to
X-RateLimit-Reset when there is no Retry-After.
Starting the pause before the 429#
APIs that send a remaining count let you pause before you are refused. A response middleware on the HTTP client sees every response, the successful ones included, and can write the same cache key:
use Psr\Http\Message\ResponseInterface;
Http::withResponseMiddleware(function (ResponseInterface $response) {
if ($response->getHeaderLine('X-RateLimit-Remaining') === '0') {
$reset = (int) $response->getHeaderLine('X-RateLimit-Reset');
Cache::put('retry-after:vendor', $reset, max(1, $reset - now()->getTimestamp()));
}
return $response;
});
After a successful call that reported zero remaining and a reset 40 seconds away, the next job through
RespectRetryAfter is released for 44 seconds without a request. This version reads the reset as a Unix
timestamp, as GitHub sends it. Convert it first for vendors that send seconds or a duration. Put the middleware on the
pending request you build for that vendor, or on Http::globalResponseMiddleware() filtered by host.
Laravel 13's Release middleware#
The checking half can also be written with the Release middleware that Laravel 13 added. A job's
middleware() method runs on every attempt, so a delay computed there is fresh each time:
use Illuminate\Queue\Middleware\Release;
public function middleware(): array
{
$wait = Cache::get('retry-after:vendor', 0) - now()->getTimestamp();
return [Release::when($wait > 0, max($wait, 0))];
}
The job then has to record the 429 itself, in handle(). That splits one rule across two places, which
is why the single middleware above is easier to keep right.
What ThrottlesExceptions does with a 429#
ThrottlesExceptions is the built-in middleware closest to this. It is a circuit breaker: after a number
of exceptions within a window, other jobs are released without running. Two things about it surprise people. Its
backoff() is in minutes, so backoff(30) releases the job for 1,800 seconds. And it does take
a closure that receives the exception, which is enough to follow the header:
use Illuminate\Queue\Middleware\ThrottlesExceptions;
public function middleware(): array
{
return [
(new ThrottlesExceptions(1, 120))
->by('vendor')
->when(fn (Throwable $e) => $e instanceof RequestException && $e->response->tooManyRequests())
->backoff(fn (RequestException $e) => RetryAfter::seconds($e->response) / 60),
];
}
The job that got the 429 is released for Retry-After seconds. The circuit for everyone else stays open
for the window you passed to the constructor, plus three seconds, whatever the vendor said: with
(1, 120) a second job is released for 123 seconds without a request. That's acceptable when the vendor's
windows are all about the same length, and wrong when they vary. Without by() the key is the job class,
so two classes calling the same API each find out on their own.
Guzzle middleware that reads Retry-After#
Two Guzzle middleware packages read the header, and both plug into Laravel's client with
Http::withMiddleware(). Each covers one half of the problem.
-
caseyamcl/guzzle_retry_middleware
retries 429 and 503 responses in place and waits for
Retry-After, whether it is whole seconds,2.0or a date. Without a usable header it backs off on its own, 1.5 seconds before the first retry. When it runs out of retries, Laravel gets the last 429 as an ordinary response. It is the packaged form of theHttp::retry()closure above, so it also sleeps in the worker.max_allowable_timeout_secscaps the sleep, but a capped wait still retries early: withRetry-After: 10and a one-second cap, the retry goes out after one second. In a queued job, keep the cap low and release the job on the 429 that comes back. Version 3 needs Guzzle 8. Version 2 covers Guzzle 6 and 7. -
poor-plebs/guzzle-retry-after-middleware
does the HTTP half of
RespectRetryAfter. It stores theRetry-Aftertime in a cache you give it (any Laravel cache store works), under a key you set per request, and refuses later requests with that key until the time has passed, without sending them. Through Laravel's client the refusal arrives as aConnectionExceptionwith a message likeRetry after 2026-10-16T20:06:56+00:00, and firesConnectionFailed. A job would treat that as a network error unless it checksgetPrevious()for the package'sRetryAfterException. A header it can't parse throws Carbon'sInvalidFormatException, and the 429 response is lost. It supports Guzzle 6 and 7, not 8, and its last release was in December 2024.
Neither one releases the job, so the job middleware is still needed with either of them.
Staying under the limit in the first place#
Everything so far reacts to a 429. When the vendor publishes its limit, pace the jobs to it as well, with
RateLimitedWithRedis and a named limiter:
// AppServiceProvider::boot()
RateLimiter::for('vendor', fn () => Limit::perMinute(100));
// In the job
public function middleware(): array
{
return [
new RespectRetryAfter('vendor'),
new RateLimitedWithRedis('vendor'),
];
}
The order matters. Job middleware runs outermost first, so with RespectRetryAfter on the outside a job
that arrives during a pause is released before it takes a slot from the limiter. The limiter counts jobs while the
vendor counts requests, so set it below the published number if a job makes more than one call. When the limit is on
concurrency rather than rate, or the vendor allows no parallel calls at all,
rate-limited APIs and Laravel queues
compares the options, including a single-worker supervisor that needs no limiter.
Testing it#
withFakeQueueInteractions() gives the job a fake queue job that records releases, so a unit test can
check the delay without a worker:
Http::fake([
'api.vendor.com/*' => Http::response('', 429, ['Retry-After' => '30']),
]);
$job = (new SyncContact($contact))->withFakeQueueInteractions();
$job->handle();
$job->assertReleased(delay: 30);
Calling handle() directly skips the job's middleware, so test the middleware by passing it the job and a
closure that runs it. The jitter makes the exact delay random, so assert on the cache key:
$this->freezeTime();
$job = (new SyncContact($contact))->withFakeQueueInteractions();
(new RespectRetryAfter('vendor'))->handle($job, fn ($job) => $job->handle());
$job->assertReleased();
$this->assertSame(now()->getTimestamp() + 30, Cache::get('retry-after:vendor'));
One trap when a test needs the API to answer differently: a second Http::fake() adds stubs rather than
replacing them, and the first stub that matches wins. Faking '*' with a 429 and later with a 200 keeps
returning the 429. Use Http::sequence() to script several responses in order.
Seeing which API throttles you, and how often#
All of this keeps a job alive through a 429. It doesn't tell you which vendor sends them, how many an hour, or whether your pause is long enough. A released job that later succeeds leaves no failed job and no log line.
Requizon, our dashboard for outgoing HTTP calls, records every call made
through Laravel's HTTP client, each Http::retry() attempt and each released job's repeat call included. Its responses chart stacks each API's calls by
status code per hour, and filtering the requests list to 429 charts only those. Failed calls keep their response
headers, so the Retry-After and X-RateLimit-* values the vendor sent are in each row's
details. Recording headers covers what is stored and how
credentials are masked.
For the retry schedule that applies once a job has thrown, see job retries, backoff and unique locks. For recording outgoing calls yourself, instrumenting Guzzle in Laravel shows the middleware that sees every transfer.
Frequently asked questions
How do I handle a 429 response in a Laravel queued job?
Check $response->tooManyRequests(), read the Retry-After header, and call $this->release() with that many seconds. Give the job a retryUntil() deadline instead of a $tries count, because every release counts as an attempt. To stop the other jobs for the same API from calling it during the wait, keep the retry time in a shared cache key and release them from a job middleware before they make a request.
Does Laravel's HTTP client respect the Retry-After header?
No. Http::retry() waits for the sleep you give it, which defaults to zero, so Http::retry(3) against a 429 sends three requests back to back. Its sleep argument can be a closure that receives the exception, and the response on a RequestException carries the Retry-After header, so you can read it there. In a queued job, only wait in place for a few seconds and release the job for anything longer.
Why does my job fail with "has been attempted too many times" when the API rate-limits it?
Because release() increments the attempt count. A job with $tries = 3 that is released on three 429s in a row is failed with a MaxAttemptsExceededException on its next pickup, before handle() runs. Define retryUntil() so the job retries until a deadline, and set $maxExceptions to limit real errors, which releases do not count toward.
Is the backoff of ThrottlesExceptions in seconds or minutes?
Minutes. ThrottlesExceptions multiplies the backoff value by 60, so backoff(30) releases the job for half an hour. backoff() also accepts a closure that receives the exception, so returning the Retry-After seconds divided by 60 releases the job for what the API asked.
How do I test that a Laravel job is released after a 429?
Fake the API with Http::fake() and a 429 response that has a Retry-After header, call withFakeQueueInteractions() on the job, run handle(), and assert with $job->assertReleased(delay: 30). handle() does not run the job's middleware, so test a middleware by calling its handle() method with the job and a closure that runs it.
Keep reading
16 min read
Rate-Limited APIs and Laravel Queues: One Request at a Time, Without Starving the Rest
How to run Laravel jobs against an API that rate-limits you and allows no parallel requests: middleware,...
9 min read
Laravel Job Retries and Backoff with ShouldBeUnique and WithoutOverlapping
What ShouldBeUnique, ShouldBeUniqueUntilProcessing and WithoutOverlapping do while a Laravel job waits out its...
11 min read
Instrumenting Guzzle in Laravel: Logging and Metrics for Every Outgoing Request
Log and measure every outgoing HTTP call in Laravel with a Guzzle middleware and on_stats, including...
13 min read
Laravel Job timeout vs retry_after: The Ordering Rule Nothing Enforces
Why a Laravel job runs twice: the rule for ordering timeout and retry_after, the defaults in each config file,...