Timeouts
Bound each execution attempt with a hard timeout, and give the task a soft deadline to finish cleanly.
Bound each execution attempt with a hard timeout, and give the task a soft deadline to finish cleanly.
timeout bounds a single execution attempt, in seconds. It defaults to 300
for every task, and the queue-wide default is configurable:
queue = Queue(default_timeout=120) # queue-wide default
@queue.task(timeout=30) # per task
def scrape(url: str) -> str: ...
scrape.apply_async(args=(url,), timeout=10) # per jobThe narrowest setting wins: per-job over per-task over queue default.
How the deadline is enforced depends on the pool:
| Pool | Enforcement |
|---|---|
"thread" | The scheduler's stale-job reap marks the attempt timed out. The Python thread is not interrupted — it keeps running to completion in the background. |
"prefork" | A watchdog thread SIGKILLs the child process at the deadline, so the work really does stop. |
This is a property of Python, not a gap: there is no safe way to interrupt an
arbitrary thread mid-call. On the thread pool a hard timeout is therefore a
bookkeeping outcome — the job is failed and retried on schedule while the
orphaned attempt still holds its worker slot until it finishes on its own. Use
soft_timeout (below) if you need the task itself to stop, or pool="prefork"
if you need the runtime to guarantee it.
A timed-out attempt is a failure, not a cancellation. It consumes one retry; if the budget still has room the job is re-enqueued with the usual backoff, and when the budget is exhausted the job moves to the dead-letter queue.
That means a task with max_retries=3 and timeout=30 can occupy a worker for
roughly two minutes across attempts before it dead-letters. Size the two
together rather than each on its own.
A hard timeout settles the job at the deadline, but on the thread pool it does
not stop the work. soft_timeout is the deadline the task polls itself, so the
task actually unwinds:
from flexiq import current_job
@queue.task(soft_timeout=25, timeout=30)
def export(dataset: str) -> str:
rows = []
for chunk in chunks(dataset):
current_job.check_timeout() # raises SoftTimeoutError past 25s
rows.extend(process(chunk))
return write(rows)check_timeout() raises SoftTimeoutError once the soft deadline has elapsed.
Because it is an ordinary exception, try/finally still runs and the task can
flush partial work, release a lock, or record where it got to.
Set soft_timeout a few seconds below timeout, so the polled deadline is
reached before the hard one:
| Setting | Behaviour |
|---|---|
soft_timeout only | Raises SoftTimeoutError at the deadline — but only where the task checks. |
timeout only | The job is failed at the deadline. The work stops only on prefork. |
| Both (recommended) | The task gets a window to exit cleanly; the hard timeout is the backstop if it does not. |
A task that never calls check_timeout() gets no benefit from
soft_timeout — nothing polls the deadline for it. Call it at natural
boundaries: between chunks, between pages, between rows.
Set a timeout on every production task. Without one a wedged task holds a worker slot until the process is restarted, and no amount of retry configuration recovers that slot.
| Setting | Scope | What it bounds |
|---|---|---|
timeout | One attempt | Execution time before the attempt is failed (and, on prefork, killed). |
soft_timeout | One attempt | Polled deadline the task observes itself. |
expires | One job | How long a job may sit unstarted before it is skipped. |
drain_timeout | Worker | How long shutdown waits for running jobs before it stops waiting. |
result_ttl | One result | How long a finished job's result is retained. |
expires is the one most often confused with timeout: it bounds staleness in
the queue, not runtime. See
Enqueue options.