Error Handling
What happens when a task fails — the retry, timeout, and dead-letter lifecycle, inspecting error history, and each SDK's exception hierarchy.
What happens when a task fails — the retry, timeout, and dead-letter lifecycle, inspecting error history, and each SDK's exception hierarchy.
When a task raises an exceptiontask throws (or its promise rejects)handler throwstask body returns an Err, or panics, FlexiQ decides
the job's fate from its retry budget. Each failing attempt is recorded,
then:
@queue.task(max_retries=3, retry_backoff=1.0, max_retry_delay=60)
def fetch(url):
resp = requests.get(url, timeout=10)
resp.raise_for_status()
return resp.json()queue.task("fetch", fetchUrl, {
maxRetries: 3,
retryBackoff: { baseMs: 1000, maxMs: 60_000 },
});Task<String> FETCH = Task.of("fetch", String.class)
.maxRetries(3)
.retryPolicy(RetryPolicy.exponential(Duration.ofSeconds(1), Duration.ofMinutes(1)));#[flexiq::task(max_retries = 3, retry_backoff_ms = "1s", retry_max_delay_ms = "1m")]
fn fetch(url: String) -> flexiq::Outcome<String> {
let body = http::get(&url).map_err(|e| flexiq::TaskError::retryable(e.to_string()))?;
Ok(body)
}Ok is success and Err is failure; which of the two a failure gets is the
constructor's choice, TaskError::retryable or TaskError::fatal. The macro
will not guess it, and there is no blanket conversion from an arbitrary error
type — that is why the body ends in a map_err.
Returning normally is success; an uncaught throw is failure.
There's no in-task "stop retrying" signal from inside a failing attempt — set
retries to 0 for a fire-once task.
There's no in-task "stop retrying" signal from inside a failing attempt — set
retries to 0 for a fire-once task.
TaskFunction declares throws Exception, so checked exceptions propagate
without wrapping. maxRetries defaults to 0 — never retry — so a fresh
task is fire-once until you opt into retries.
A failing attempt can also decide for itself: throwing NonRetryableException
dead-letters the job immediately, RetryableException insists on a retry. See
Typed signals from the
handler.
A failing attempt does decide for itself, and it is the only thing that does:
TaskError::fatal(..) dead-letters the job at once whatever budget is left,
TaskError::retryable(..) spends one. Set max_retries = 0 when the whole task
should be fire-once rather than one failure inside it. See
Retries.
A per-attempt timeout bounds a single execution attempt. When it elapses, the attempt is marked failed and timed-out — this consumes a retry like any other failure, and once the budget is exhausted the job dead-letters with the timed-out flag set on the outcome, so hooks and event listeners can tell a timeout apart from other errors.
@queue.task(timeout=30) # 30 second timeout
def long_task():
...queue.task("slow", handler, { timeoutMs: 30_000 });Task<String> SLOW = Task.of("slow", String.class).timeout(Duration.ofSeconds(30));#[flexiq::task(timeout = "30s")]
fn long_task() -> flexiq::Outcome<()> {
Ok(())
}The pool does not race the handler. The scheduler's stale-job reap notices the overrun from outside and fails the attempt, so the blocking thread keeps running until the body returns and its late result is discarded.
The scheduler detects an exceeded timeout externally — its maintenance reaper checks roughly every 5 seconds — and marks the job failed, which triggers the retry/DLQ logic above.
The dispatcher races the task against the timeout (tokio::time::timeout).
A timeout fails the attempt but cannot stop the handler, which keeps running —
and the job's retry can start beside it and repeat its side effects. It does
not abort the task's
AbortSignal either — that
fires only on a cancel request. Give the work its own deadline, shorter than
timeoutMs, and combine it with the task's signal —
AbortSignal.any([signal, AbortSignal.timeout(ms)]), on Node 20.3 or later —
so it stops on a cancel too and cleans up before the attempt is failed. Keep its
side effects idempotent. See
Timeouts for an example.
The dispatcher races the handler against the timeout. Unset (or 0) means
no limit.
The scheduler detects an exceeded timeout externally — its maintenance reap runs
every reap_interval polls, roughly every 5 seconds at the default
SchedulerConfig — and marks the job failed, which triggers the retry/DLQ logic
above. Nothing races the handler.
Unset is not "no limit" here: timeout_ms defaults to 300_000, five minutes, on
every enqueue, and .timeout_ms(..) overrides it per call.
Timeout reaping marks the job as failed, but it does not kill the running threadtaskhandlerblocking thread: the code keeps running until it returns, and the late result is discarded. Always set a timeout on production tasks — without one, a wedged tasktaskhandlertask can hold a worker slotslotthreadslot indefinitely.
For CPU-bound synchronous work that might hang, honoring AbortSignal isn't
enough on its own — see
Troubleshooting: worker is
unresponsive for offloading it.
Make long-running handlers cooperative: poll
flexiq.isCancelRequested(jobId) or bound their own blocking calls, so the
underlying work actually stops. See
Cancellation.
Nothing preempts a blocking thread, so make a long-running body cooperative:
bound its own blocking calls, and poll
Storage::is_cancel_requested(&job.id, None) between units of work. That needs
the Job, which a macro-expanded body never receives — see
Cancellation for the
hand-written Task impl that does.
While the hard timeout above is enforced by the scheduler from the outside,
a soft timeout lets the task itself react to time pressure — it's
cooperative, so the task must call check_timeout() at safe points:
from flexiq import current_job
@queue.task(soft_timeout=30)
def long_task():
for chunk in data_chunks:
process(chunk)
current_job.check_timeout() # raises SoftTimeoutError after 30s| Timeout type | Mechanism | Exception |
|---|---|---|
Hard timeout (timeout) | Scheduler reaps the job externally | TaskTimeoutError (internal) |
Soft timeout (soft_timeout) | Task checks elapsed time via check_timeout() | SoftTimeoutError |
Only the hard one, enforced by the scheduler from outside. A body that wants to react to time pressure measures it itself and ends the attempt on its own terms:
use std::time::{Duration, Instant};
#[flexiq::task(timeout = "60s")]
fn long_task(chunks: Vec<String>) -> flexiq::Outcome<()> {
let deadline = Instant::now() + Duration::from_secs(30);
for chunk in chunks {
process(&chunk);
if Instant::now() > deadline {
return Err(flexiq::TaskError::retryable("soft deadline passed").into());
}
}
Ok(())
}That spends a retry, exactly as the hard timeout would. Use
TaskError::fatal instead when overrunning means the work will never fit.
Every attempt's error is recorded — one entry per failed attempt:
queue.job_errors(job_id) # one entry per failed attempt
queue.list_jobs(status="failed")
queue.dead_letters() # exhausted jobsawait queue.getJobErrors(id); // one entry per failed attempt
await queue.listJobs({ status: "failed" });
await queue.deadLetters(); // exhausted jobsList<JobError> errors = queue.jobErrors(jobId); // one entry per failed attempt
List<Job> failed = queue.listJobs(JobFilter.builder().status(JobStatus.FAILED).build());
List<DeadJob> dead = queue.listDead(50, 0); // exhausted jobsNone of these reads is on FlexiQ — queue.storage() is the door to them:
use flexiq::{JobStatus, Storage};
let storage = queue.storage();
let errors = storage.get_job_errors(&job_id, None)?; // one entry per failed attempt
let failed = storage.list_jobs(Some(JobStatus::Failed as i32), None, None, 50, 0, None)?;
let dead = storage.list_dead(50, 0, None)?; // exhausted jobsFlexiQ::list_jobs(limit, offset) is the unfiltered form. Every listing comes
back blob-free, so payload and result are empty on these rows — read the
full job with queue.get_job(&id).
Each error entry carries an id, the job it belongs to, an attempt number (0-indexed), the error message, and a failure timestamp:
| Field | Description |
|---|---|
id | Unique error record ID |
job_idjobIdjobIdjob_id | The job this error belongs to |
attempt | Attempt number (0-indexed) |
error | Error message |
failed_atfailedAtfailedAtfailed_at | Timestamp in milliseconds |
A job handle also exposes its own history directly:
job = unreliable_task.delay()
for error in job.errors:
print(f"Attempt {error['attempt']}: {error['error']} at {error['failed_at']}")There is no job handle to hang a history off — Job is the row, read back by id.
The most recent failure is on the row itself; the whole history is the storage
read:
use flexiq::Storage;
if let Some(job) = queue.get_job(&job_id)? {
println!("last error: {:?}", job.error); // the canonical TaskError JSON
}
for entry in queue.storage().get_job_errors(&job_id, None)? {
println!("attempt {}: {} at {}", entry.attempt, entry.error, entry.failed_at);
}entry.attempt is the retry_count the job carried when that attempt failed, so
the first one is 0.
For deep debugging, query the jobs table directly (SQLite shown; the same status codes apply on every backend):
sqlite3 myapp.db "SELECT id, task_name, status, error FROM jobs WHERE status = 3 LIMIT 10;"Status codes: 0=pending, 1=running, 2=complete, 3=failed, 4=dead,
5=cancelled.
Same error on every attempt — the failure is deterministic (bad arguments, a missing dependency). Fix the root cause, then replay. Intermittent errors — the failure is transient (a network timeout, a flaky dependency); replaying will likely succeed.
dead = queue.dead_letters(limit=20)
new_job_id = queue.retry_dead(dead[0]["id"])const dead = await queue.deadLetters();
queue.retryDead(dead[0].id); // re-enqueue (preserves notes/metadata)List<DeadJob> dead = queue.listDead(20, 0);
String newJobId = queue.retryDead(dead.get(0).id); // re-enqueue (preserves metadata)use flexiq::Storage;
let dead = queue.storage().list_dead(20, 0, None)?;
let new_job_id = queue.storage().retry_dead(&dead[0].id, None)?;retry_dead takes the dead-letter entry's own id, not the
original_job_id beside it, and returns the id of the job it created.
Replayed jobs preserve the original job's priority, max_retries,
timeout, and result_ttl settings — no need to re-specify them.
A retried dead job re-enters as pending with a fresh retry budget. Notes
and metadata survive the DLQ round-trip. See
Dead-letter queue
for listing, deleting, and purging.
A retried dead job re-enters as PENDING with a fresh retry budget.
Metadata survives the DLQ round-trip. See
Dead-letter queue
for listing, deleting, and purging.
A retried dead job re-enters as pending with a fresh retry budget, keeping the
original's priority, max_retries, timeout_ms, result_ttl_ms, queue
and namespace — the entry stored every one of them. metadata and notes
survive the round trip too. depends_on does not: a replayed job has no
dependencies. See Dead-letter
queue for listing, deleting, and purging.
See Retries for the full dead-letter queue API — listing, purging, and error history.
See Retries for the backoff
curve and the retryable/fatal choice, and
Dead-letter queue for the
Storage calls that list, replay and purge entries — none of them is on
FlexiQ.
Cancellation is cooperative — a running tasktaskhandlertask must check for it at safe points; nothing preempts it mid-execution. One that never checks runs to completion.
from flexiq import current_job
@queue.task()
def long_task(items):
for item in items:
process(item)
current_job.check_cancelled() # raises TaskCancelledError
# From the caller:
queue.cancel_running_job(job_id)import { currentJob } from "@byteveda/flexiq";
queue.task("download", async (url: string) => {
const { signal } = currentJob() ?? {};
const res = await fetch(url, { signal });
return res.text();
});
queue.requestCancel(jobId);flexiq.requestCancel(jobId); // caller side
// handler side: poll between units of work
for (Chunk chunk : chunks) {
if (flexiq.isCancelRequested(jobId)) {
throw new InterruptedException("cancelled");
}
process(chunk);
}queue.request_cancel(&job_id)?; // running job: ask it to stop
queue.cancel(&job_id)?; // still pending: cancel it outrightThere is no handle a task body can reach for. A macro-expanded body takes its
decoded arguments and nothing else, so it has no job id to poll with. A
hand-written Task::run_encoded(job: &Job) does receive the job:
job.cancel_requested is the flag as of dispatch, and
Storage::is_cancel_requested(&job.id, None) re-reads it between units of work.
See Cancellation for cancelling a still-pending job and progress reporting.
See Cancellation for cancelling a still-pending job, progress reporting, and getting the job id into a handler.
See Cancellation for cancelling
a still-pending job, and for getting the job id into a body that has to poll for
the flag — #[flexiq::task] hands a body its decoded arguments and nothing else.
Whatever a binding gives you to react with fires as the core decides each outcome:
from flexiq import Queue, TaskMiddleware, EventType
class AlertOnFailure(TaskMiddleware):
def after(self, ctx, result, error):
if error:
log.error(ctx.task_name, error) # each failing attempt
def on_retry(self, ctx, error, retry_count):
metrics.incr("retry", tags={"task": ctx.task_name})
def on_dead_letter(self, ctx, error):
alert_ops(ctx, error)
queue = Queue(db_path="tasks.db", middleware=[AlertOnFailure()])
queue.on_event(EventType.JOB_DEAD, lambda _type, payload: page_oncall(payload))queue.use({
onError: (ctx, err) => log.error(ctx.taskName, err), // each throwing attempt
onRetry: (e) => metrics.inc("retry", e.taskName),
onDeadLetter: (e) => alertOps(e),
});
queue.on("job.dead", (e) => pageOncall(e));queue.use(new Middleware() {
@Override
public void onError(TaskContext context, Throwable error) {
log.error(context.taskName, error); // each throwing attempt
}
@Override
public void onRetry(OutcomeEvent event) {
metrics.increment("retry", event.taskName);
}
@Override
public void onDeadLetter(OutcomeEvent event) {
alertOps(event);
}
});
Worker worker = queue.worker()
.handle(FETCH, this::fetch)
.on(EventName.DEAD, event -> pageOncall(event))
.start();The Rust SDK has no middleware hooks and no events. The nearest seam is
flexiq_core::Worker::on_outcome, which WorkerBuilder does not expose — build
the worker through flexiq_core::Worker directly if you need it.
Otherwise the job row is the record. A failed attempt writes the contract's
{"errtype","message","traceback"} JSON into job.error, and every attempt is
kept separately:
use flexiq::Storage;
for entry in queue.storage().get_job_errors(&job_id, None)? {
eprintln!("attempt {}: {}", entry.attempt, entry.error);
}See Middleware for the full hook lifecycle and execution order.
See Middleware and Events for the full hook and event lists.
See Middleware and Events for the full hook and event lists.
See Middleware for what
flexiq_core::Worker::on_outcome reaches and what it costs to route a worker
through it, and Events for why there
is no stream to subscribe to.
A throw fails only the current attempt. To fail fast with no retries,
set retries to 0; to make failures safe to retry, keep
Use a finally block (or hooks) to release resources regardless of outcome:
@queue.task()
def process_file(path):
tmp = download_to_temp(path)
try:
return parse(tmp)
finally:
os.unlink(tmp)queue.task("processFile", async (path: string) => {
const tmp = await downloadToTemp(path);
try {
return await parse(tmp);
} finally {
await fs.unlink(tmp);
}
});Task<String> PROCESS_FILE = Task.of("process_file", String.class);
queue.worker().handle(PROCESS_FILE, path -> {
String tmp = downloadToTemp(path);
try {
return parse(tmp);
} finally {
Files.deleteIfExists(Path.of(tmp));
}
});There is no finally; Drop is the equivalent, and it covers the ? path, an
early return and a panic alike:
/// Removes the temp file however the body leaves.
struct TempFile(std::path::PathBuf);
impl Drop for TempFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
#[flexiq::task]
fn process_file(path: String) -> flexiq::Outcome<String> {
let tmp = TempFile(download_to_temp(&path));
let parsed = parse(&tmp.0).map_err(|e| flexiq::TaskError::retryable(e.to_string()))?;
Ok(parsed)
}The pool catches a panicking handler, so the unwind that runs drop happens
before the failure is recorded.
The default SmartSerializer uses MessagePack for common types and falls
back to cloudpickle for anything else. The cloudpickle fallback fails on:
Fix: pass simple, serializable data (strings, numbers, dicts, lists) as task arguments, and reconstruct complex objects inside the task:
# Bad — passing a connection object
@queue.task()
def query(conn, sql): # conn can't be pickled
return conn.execute(sql)
# Good — pass connection info, create inside the task
@queue.task()
def query(db_url, sql):
conn = create_connection(db_url)
return conn.execute(sql)See Serialization for the full serializer
list, including JsonSerializer for cross-language or untrusted payloads.
Use retry_on and dont_retry_on to control which exceptions trigger
retries:
@queue.task(
max_retries=5,
retry_on=[ConnectionError, TimeoutError],
dont_retry_on=[ValueError],
)
def call_api(url):
resp = requests.get(url, timeout=10)
resp.raise_for_status()
return resp.json()See Retries — Exception Filtering for details.
Run tasks synchronously and inspect errors without a worker using test mode:
with queue.test_mode() as results:
risky_task.delay()
if results[0].failed:
print(results[0].error)
print(results[0].traceback)There is no serializer to choose and no fallback codec: a payload is the wire CBOR envelope, fixed. What can still fail is a value with no representation in that envelope, and each direction has its own type.
| Type | Returned when |
|---|---|
EncodeError | An argument cannot be written into the call envelope. A u64 past i64::MAX is the ordinary case — nothing widens silently. |
DecodeError | A payload cannot be read back into the task's declared parameter types, including a call whose positional-argument count does not match, or one carrying kwargs. |
An encoding failure is carried on the TaskCall rather than raised by call(..)
— that call mirrors the task's own signature and has nowhere to put a Result,
and a batch stays one expression. It surfaces from enqueue, naming the task:
let err = queue.enqueue(takes_a_big_number::call(u64::MAX)).unwrap_err();
// task `takes_a_big_number` could not encode its arguments: …A decoding failure happens on the worker instead, and it is fatal — no amount of retrying makes a payload decodable — so the job dead-letters with a message naming the task.
Pass values the envelope can hold, and reconstruct anything else inside the body — a task takes its arguments and nothing else, so a connection or a handle was never going to travel anyway:
// Bad: a value past i64::MAX has no representation, and `enqueue` says so.
#[flexiq::task]
fn count_rows(upper_bound: u64) -> flexiq::Outcome<u64> {
Ok(upper_bound)
}
queue.enqueue(count_rows::call(u64::MAX))?; // Err, naming the task
// Good: pass what identifies the work, and build the rest in the body.
#[flexiq::task]
fn query(db_url: String, sql: String) -> flexiq::Outcome<()> {
let retry = |e: std::io::Error| flexiq::TaskError::retryable(e.to_string());
let conn = connect(&db_url).map_err(retry)?;
conn.execute(&sql).map_err(retry)?;
Ok(())
}#[flexiq::task] leaves the body callable: query::run(..) is the original
function with its original signature, so a unit test exercises it with no queue,
no storage and no worker. FlexiQ::in_memory() is the other half — a private
database that dies with the handle — when the test needs an enqueue as well.
All FlexiQ exceptions inherit from FlexiQError, so you can catch the
base class for broad handling:
FlexiQError (base)
├── TaskTimeoutError — hard timeout exceeded
├── SoftTimeoutError — soft timeout exceeded (check_timeout)
├── TaskCancelledError — task cancelled (check_cancelled)
├── MaxRetriesExceededError — all retry attempts exhausted
├── SerializationError — serialization/deserialization failure
├── CircuitBreakerOpenError — circuit breaker is open
├── RateLimitExceededError — rate limit exceeded
├── JobNotFoundError — job ID not found (also a KeyError)
└── QueueError — queue-level operational error
from flexiq import FlexiQError, SoftTimeoutError, TaskCancelledError
try:
result = job.result(timeout=30)
except FlexiQError as e:
print(f"FlexiQ error: {e}")Import any exception directly from the flexiq package:
from flexiq import TaskCancelledError, SoftTimeoutError, SerializationErrorEvery SDK error extends FlexiQError, so you can catch the base class for
broad handling:
| Error | Thrown when |
|---|---|
TaskNotRegisteredError | A worker dequeued a job whose task name was never registered. |
JobFailedError | An awaited job (queue.result()) failed or dead-lettered. |
JobCancelledError | An awaited job was cancelled. |
ResultTimeoutError | An awaited job didn't settle within the timeout. |
QueueError | A queue-level configuration or operational error. |
LockNotAcquiredError | A distributed lock is held by another owner. |
LockLostError | A held lock's lease was lost before the guarded section finished. |
SerializationError | A payload or result failed to (de)serialize. |
CryptoError | A codec failed to decrypt or verify a payload. Subtype of SerializationError. |
NotesValidationError | A notes object violates the structured-notes contract. |
ResourceError | Base class for resource errors. |
ResourceNotFoundError | Resolving a resource name that was never registered. Subtype of ResourceError. |
ResourceScopeError | A resource is resolved from a scope that outlives it. Subtype of ResourceError. |
ResourceUnavailableError | A pooled resource couldn't be checked out before its acquire timeout. Subtype of ResourceError. |
WorkflowError | A workflow definition, submission, or query error. |
PredicateRejectedError | An enqueue-time predicate gate rejected the submission. |
ProxyError | A proxy handler, signature, expiry, purpose, or allowlist failure. |
InterceptionError | An enqueue interceptor rejected, misbehaved, or redirected illegally. |
import { FlexiQError, JobFailedError } from "@byteveda/flexiq";
try {
const result = await queue.result(jobId, { timeoutMs: 30_000 });
} catch (err) {
if (err instanceof FlexiQError) {
console.error("FlexiQ error:", err.message);
}
}Every SDK error is an unchecked FlexiQException; the errors package
narrows it so callers can catch exactly what they care about:
| Exception | Raised when |
|---|---|
ConfigurationException | The client was misconfigured — missing connection URL, unusable storage directory. |
SerializationException | A payload or result failed to (de)serialize. |
CryptoException | A signing or encrypting serializer failed — HMAC mismatch, bad tag/IV. Subtype of SerializationException. |
InterceptionException | An interceptor rejected the enqueue; no job was created. |
PredicateRejectedException | An enqueue gate returned Reject; no job was created. |
EnqueueSkippedException | A gate returned Skip on enqueue — use tryEnqueue to get an empty Optional instead. |
LockException | A distributed lock operation failed or was interrupted. |
ResourceException | A worker resource could not be resolved — unknown name, scope violation, exhausted pool. |
ProxyException | A proxy ref failed to verify or reconstruct. |
WorkflowException | A workflow could not be driven to completion — run not found, missing payload or condition. |
WebhookException | A webhook could not be stored, loaded, signed, or its payload encoded. |
try {
queue.enqueue(FETCH, url);
} catch (FlexiQException e) {
log.error("FlexiQ error: {}", e.getMessage());
}There is no hierarchy and no base class to catch. Every operation returns
flexiq::Result<T>, which is Result<T, QueueError> — one enum, so a match on
it is exhaustive. The variants a caller usually names:
| Variant | Returned when |
|---|---|
JobNotFound(id) | No job exists with that id. |
TaskNotRegistered(name) | A job referenced a task name with no registered handler. The shell's own pool reports that case as a fatal TaskError instead, since retrying cannot make the task runnable on that worker. |
Serialization(msg) | A payload or result could not be encoded or decoded. |
Config(msg) | Invalid or inconsistent configuration — an empty debounce key, a max_wait_ms below its window. |
RateLimitExceeded(key) | A queue or task rate limit turned the operation away. |
QueueFull { queue, pending, cap } | A debounced enqueue hit the queue's admission cap. The Display string is a cross-SDK contract, so the two integers stay at the end where a queue name cannot be mistaken for them. |
Timeout(msg) | A job exceeded its execution timeout. |
LockNotAcquired(name) | A distributed lock is held by another owner. |
ContractTooOld { speaks, required } | The storage requires a newer contract level than this build speaks. Raised at FlexiQ::open / from_storage, before anything else runs. |
ClaimLost(job_id) / StepDiverged { .. } | A durable step lost its fence, or a commit does not match what is stored at that position. |
Storage(_) / Pool(_) | The database, or its connection pool, failed. |
Other(msg) | What the shell refuses by hand: a debounce window riding a batch, a periodic operation on a namespaced handle, one task name registered twice on one worker. |
TaskError is the other error type in play and is deliberately not one of these.
A QueueError means the queue could not be reached; a TaskError means the work
failed. ? does not bridge them, and a task body's error type is Abort, not
QueueError, for exactly that reason.
Troubleshooting
covers symptom-based debugging — a task not registered on the worker,
SQLite lock contention, jobs stuck in running, high latency, and growing
databases.