Build1 publisher3 min readPublished
A missing retry_after key hands a running Laravel job to a second worker
Laravel's shipped config keeps a 60-second worker timeout under a 90-second queue lease, but the framework's fallback for any connection written by hand is 60, and nothing in the boot path checks the pair. The symptom then depends on tries.
The Engineer · Build desk

What happened
- Laravel keeps the queue lease and the worker timeout in separate config files, and no code compares them at boot, at dispatch, or when a supervisor starts a worker.
- retry_after is a lease: popping a job reserves it for that many seconds, and when the reservation expires the job goes back on the ready queue even if a worker is still running it.
- When a connection array has no retry_after key, the Redis connector falls back to 60 seconds, matching the default worker timeout instead of the 90 in the shipped file.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- exposure A hand-written connection for a dedicated queue, or one built in a test fixture, inherits the 60-second fallback, so duplicate concurrent execution is reachable from a diff that passes review.
- constraint Because the sweep cannot distinguish a worker killed mid-deploy from a worker still working, at-least-once is the strongest guarantee on offer, and idempotency has to live inside the job body.
- decision Anyone who wants the ordering guaranteed has to write the assertion themselves and choose the margin at each step, because the framework supplies the rule in prose only.
- cost Under tries => 1 the bill goes to whoever is on call, reading a failed jobs entry that names work which already completed.
On Redis, the reservation is a score in a sorted set. One Lua script does the whole pop: it lpops the payload off the ready list, increments the payload's attempts counter, and zadds the result to the reserved set with a score of now plus retry_after [11]. Nothing watches that score. The cleanup happens on the next pop of the same queue, which calls migrate() first, and migrate() moves two things back onto the ready list: delayed jobs that have come due, and reserved jobs whose lease has run out [12]. According to the dev.to write-up, migrate() cannot tell a job whose worker was killed by a deploy from a job whose worker is still running it, because both are an expired score [13].
The two clocks do not start together. The lease begins at pop(), and the worker's timeout alarm is armed a few steps later, so matching values mean the reservation always expires first [7]. retry_after belongs to the queue connection and sets how long a reservation is honoured; timeout belongs to the worker and bounds how long one job may occupy a process [16].
The 90 in a stock skeleton is a line in your configuration file, not a framework default. The Redis connector reads Arr::get($config, 'retry_after', 60) [6]. A second Redis connection written by hand for a dedicated queue, or one built inside a test, gets 60 against a 60-second worker timeout, which is zero slack [1]. That connection passes review without comment and is wrong from the first job that runs long [15]. The 60/90 pairing describes an untouched skeleton, and timeout can also be set on the job class itself [1], so a team that has tuned one job has to check that job rather than the shipped numbers.
Which symptom you get is decided by a third number in a third place. Horizon's published config ships tries => 1 [8]. The script increments attempts on every pop, so the second reservation puts the job at two attempts against a limit of one, and work that finished is recorded in failed jobs with MaxAttemptsExceededException [2]. Set tries to 2 or more and both workers run the job to completion, both report success, and nothing is recorded [9]. The database driver reaches the same outcome through SQL instead of a sorted set, in isReservedButExpired() [14].
The ordering the post gives is job timeout < supervisor timeout < retry_after, with margin at each step, and it says nothing in Laravel tells you when that ordering stops holding [10]. The margin has to cover the worker timeout plus whatever the process spends between the pop and the alarm being armed [7]. No check runs at boot, at dispatch, or when a supervisor starts a worker [2], so the cheap version is a test that walks config('queue.connections') and asserts each retry_after is larger than the timeout of the workers running on it.
What to watch
- Whether Laravel adds a boot-time or dispatch-time assertion comparing retry_after against the worker timeout.
- Whether the RedisConnector fallback of 60 is raised to match the 90 that ships in config/queue.php.
- Whether Horizon's published config keeps tries => 1, which decides whether the failure is visible or silent.