Failure Handling
quiv has three optional settings for a task that runs too long, fails, or starts at the same moment as many other tasks: timeout, max_retries with retry_backoff, and jitter. All three are parameters of add_task().
Timeouts
A timeout is cooperative. When a job runs for longer than timeout seconds, quiv sets the stop event of that job. This is the same signal that cancel_job() sends.
What happens next depends on the handler:
- The handler checks
stop_eventand returns. The job finalizes ascancelled, anderror_messagereads"Job exceeded timeout of {timeout}s". - The handler ignores
stop_event. It keeps its thread in the pool until it returns, because quiv never kills a thread. The job still finalizes ascancelled.
There is no separate job status for a timeout. A timeout is a cancellation that carries an error message.
If a handler passes its timeout and also raises, the timeout message comes first, and quiv adds the exception after it: ... (handler raised: ...). This keeps a timeout easy to tell apart from a failure and from a manual cancellation.
def poll_api(stop_event):
while not stop_event.wait(1):
do_one_poll()
scheduler.add_task("poll", poll_api, interval=60, timeout=30)
The scheduler loop enforces timeouts. It wakes for the deadline that comes soonest, so it reacts in milliseconds. No polling interval limits it.
Tip
Check stop_event often in your handlers. See Cancellation. The same check makes cancel_job() and timeouts both work.
Retries
quiv retries a job only when the job fails. A job fails when an exception left the handler, or when a database error stopped the job before the handler started (see When the database fails). A cancelled job never retries, because a cancellation is deliberate, and a timeout is a cancellation. A job that raises and is cancelled as well counts as cancelled.
When a job fails and retries remain, quiv schedules the next run at now + retry_backoff * 2**(failures_so_far - 1). The delay doubles every time: retry_backoff seconds before the first retry, twice that before the second, and four times that before the third.
The delay grows quickly. A large max_retries together with a large retry_backoff can move the next run hours into the future.
quiv never schedules a run later than 30 December 9999 (UTC), because Python cannot represent a date after year 9999. A retry backoff, an interval, a jitter, or a delay that goes past that date is clamped to it. The task stays active, so run_task_immediately() still runs it, and a run that succeeds resets the counter.
scheduler.add_task("flaky-sync", sync_upstream, interval=300, max_retries=3, retry_backoff=10)
- A run that succeeds resets the counter of consecutive failures.
- When the retries run out, a recurring task returns to its normal interval and the counter resets. quiv deletes a run-once task.
- Each
Jobrecords itsattemptnumber.1is the first try, and2is the first retry. Event.JOB_RETRYINGfires afterJOB_FAILEDwhen quiv schedules a retry. It carries the usual(event, task, job)payload. See Event Listeners.- If you pause a task while its job runs, and that job fails, the retry waits for
resume_task().JOB_RETRYINGstill fires, and the counter still goes up. Thedelayofresume_task()sets the time of the retry, not the backoff.
Jitter
jitter=J adds a random offset of 0 to J seconds to each recurring next-run time. It applies in both fixed_interval modes.
Use jitter when many tasks share one interval. Without it, those tasks align to the same interval boundaries and start at the same moment, which puts all of the load in one instant. quiv draws a new random offset for every run, so the schedules move apart over time.
for shard in range(20):
scheduler.add_task(f"sync-{shard}", sync_shard, interval=60, jitter=5, args=(shard,))
quiv does not add jitter to the initial delay, because the caller sets that value directly. quiv does not add jitter to the retry backoff either, so retries stay predictable.
When the database fails
quiv keeps its tasks and jobs in a temporary SQLite database. A read or a write on that database can fail, for example when the disk is full or when the database stays locked on slow storage. quiv cannot prevent this. It limits the cost of one error to one job, and the scheduler continues to run:
- Before the handler starts. The handler does not run. The job finalizes as
failed,error_messageholds the database error, andJOB_FAILEDfires. The task returns toactive, somax_retriesapplies and the next run starts on schedule. - While the loop dispatches a task. The loop logs
Error in scheduler loopand waits 5 seconds. The task returns toactive, and the loop dispatches it again. Ifpause_task()paused the task in the meantime, the task stays paused. - After the handler returns. quiv logs the error and always frees the worker. If quiv cannot write the end status, the job row keeps the status
running. If quiv cannot return the task toactive, the task staysrunningand does not run again until the process restarts. The log line names the task. To find such a task, compare two values:stats().tasks_by_status.get("running", 0)stays higher thanstats().active_jobs. To clear it, callremove_task()and thenadd_task(). - When quiv cannot write the end status, or cannot read the finished job back. The
JOB_*event fires, andwait_for_job()andwait_for_task()return. Both get aJobmade from the values that quiv tried to write: the end status, the duration, and the error of the handler. quiv never gives a listener or a waiter a job that still has the statusrunning. If the end status was not written, the job row keeps the statusrunning.get_job()returns that row, and a later call towait_for_job()for that job waits until its timeout. - During the history cleanup. quiv logs
Job history cleanup failed, and tries again 60 seconds later. Dispatch continues.