Skip to content

Build1 publisher3 min readPublished

A 10-minute retryUntil window expires 1,430 minutes before the worker ever sees the job

Laravel evaluates retryUntil at push time and serializes the deadline into the payload, so a job delayed by a day fails on arrival with an exception that blames attempt counts rather than the clock.

The Engineer · Build desk

Illustration accompanying A 10-minute retryUntil window expires 1,430 minutes before the worker ever sees the job

What happened

  • In Oussama Mater's example, SendAbandonedCartReminder returns now()->addMinutes(10) from retryUntil() and is then dispatched with a one-day delay to nudge an abandoned cart.
  • Laravel evaluates retryUntil() at push time and bakes the expiry into the payload, so the worker that picks the job up a day later fails it with MaxAttemptsExceededException instead of running it.
  • One job class with no $tries therefore behaves two ways: attempted once and marked failed under queue:work, retried indefinitely under a custom Horizon supervisor that omits tries.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint retryUntil is a wall-clock deadline measured from push, so a delayed job needs a window covering the delay plus the retry budget; the constraint also binds when a queue backlog, not a delay() call, separates push from availability.
  • exposure The recorded reason names an attempt limit, so whoever triages the failure starts on $tries and backoff while the push-time timestamp inside the payload goes unread.
  • contradiction Mater's account of Horizon's zero default contradicts the docs sentence he quotes, which means a team citing those docs to justify omitting tries inherits unlimited retries; only his debugging carries that claim here.
  • decision Pinning $tries on the job class settles retry behaviour identically under queue:work and Horizon, and removes the need to know which default is in force.

The deadline travels with the job. When dispatch runs, Laravel calls retryUntil() immediately and bakes the resulting timestamp into the payload that goes onto the queue [3]. Nothing re-evaluates it on release. A worker picking the job up a day later compares its own clock against a value computed before the delay began, finds it stale, and fails the job [4].

The delay is one day, or 1,440 minutes, and retryUntil grants 10 [1]. The deadline is therefore 1,430 minutes in the past at the moment the job first becomes available [11]. handle() is never entered [2].

MaxAttemptsExceededException is the misdirection [4]. The recorded reason points at an attempt limit for a failure whose actual cause is a timestamp fixed a day earlier, before any attempt existed. The worker fails the job outright [4]; it is a failure, not a disappearance. The honest complaint is that an operator reading that reason goes looking at $tries and backoff, and the payload's frozen deadline is not where triage starts.

Delay is the obvious trigger, but it is not the only one. Since the deadline is fixed at push, any interval between push and availability longer than the window ends the same way [12]. A backed-up queue satisfies that condition as readily as an explicit delay() call.

The second trap in Mater's post arrives through housekeeping. He emptied 'defaults' => [] and wrote a supervisor named data-import-supervisor carrying connection, queue, balance, maxProcesses and timeout, with no tries key [8]. The documentation sentence he quotes says Horizon defaults to a single attempt when the tries option is unset, unless the job class defines $tries [5]. Mater reports that this only holds when tries is explicitly set to 1 somewhere, and that Horizon's real default is zero, which means unlimited [6]. So one class with no $tries behaves two ways: attempted once and marked failed under queue:work, retried indefinitely under the renamed supervisor [7]. His conclusion is to pin $tries on the class [13].

That claim about the zero default comes from Mater's own debugging; he does not show a doc line stating it. It is cheap to verify in your own config, and the verification is the same work as the fix.

There's an unfinished thread here too. A ShouldBeUnique job takes a lock when it is pushed and releases it after a worker processes it [9]. The post begins asking what happens to that lock when the job never gets to release it, names retryUntil as one route to that state, and the supplied text breaks off there [10]. A job failed on arrival, before handle() runs, is exactly the shape the question describes. Whether Laravel's failure path clears the unique lock in that case is not settled by the material.

For anything dispatched with a delay, the sizing rule follows from the mechanism: the retryUntil window has to cover the delay plus whatever retry budget you actually wanted [12]. Sizing it to the retry budget alone buys zero attempts.

What to watch

  • Whether Laravel documents retryUntil as a push-time deadline, or re-evaluates it when a delayed job is released.
  • Whether the Horizon docs sentence about a single attempt is corrected to describe the zero default Mater reports.
  • The rest of that post's unique-jobs section, which breaks off while pointing at retryUntil as a route to a stranded ShouldBeUnique lock.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories