Skip to content

Error Handling

Taskora provides a rich error hierarchy to distinguish between different failure modes. All errors extend TaskoraError.

Error Classes

ValidationError

Thrown when input or output data fails schema validation.

ts
try {
  await handle.result
} catch (err) {
  if (err instanceof ValidationError) {
    console.log(err.issues) // Standard Schema issues array
  }
}

RetryError

Thrown by ctx.retry() to trigger a manual retry. Always retries (bypasses retryOn/noRetryOn filters) unless max attempts are exhausted.

ts
throw ctx.retry({ delay: 5000, reason: "Rate limited" })
// or
throw new RetryError("Rate limited", { delay: 5000 })

TimeoutError

Thrown when a job exceeds its timeout. Not retried by default — add to retryOn explicitly if you want timeout retries.

ts
taskora.task("slow-task", {
  timeout: 5000,
  retry: {
    attempts: 3,
    retryOn: [TimeoutError], // opt-in to retry on timeout
  },
  handler: async (data, ctx) => { /* ... */ },
})

JobFailedError

Thrown by handle.result / handle.waitFor() when the job failed permanently.

ts
try {
  await handle.result
} catch (err) {
  if (err instanceof JobFailedError) {
    console.log(err.message) // original error message
  }
}

CancelledError

Thrown when a job is cancelled via handle.cancel().

ts
try {
  await handle.result
} catch (err) {
  if (err instanceof CancelledError) {
    console.log(err.reason) // optional cancellation reason
  }
}

ThrottledError

Thrown when dispatch is rejected by throttle (only with throwOnReject: true).

ts
try {
  sendEmailTask.dispatch(data, {
    throttle: { key: "emails", max: 100, window: "1h" },
    throwOnReject: true,
  })
} catch (err) {
  if (err instanceof ThrottledError) {
    console.log(err.key) // "emails"
  }
}

DuplicateJobError

Thrown when dispatch is rejected by deduplication (only with throwOnReject: true).

ts
if (err instanceof DuplicateJobError) {
  console.log(err.key)        // dedup key
  console.log(err.existingId) // ID of the existing job
}

ExpiredError

Stored as the error when a job's TTL expires before processing.

StalledError

Stored as the error when a job exceeds maxStalledCount and is moved to failed.

SchemaVersionMismatchError

A library-internal safety net for taskora upgrades that you will almost never see — we've built CI snapshot tests, a decoupled bump policy, and rolling-upgrade semantics specifically so this error doesn't fire in practice. If something ever does slip through all of that, app.start() / app.ensureConnected() throws before any worker, scheduler, or dispatch touches Redis, so your data is never in an ambiguous state.

ts
try {
  await app.start()
} catch (err) {
  if (err instanceof SchemaVersionMismatchError) {
    console.error(err.code)     // "theirs_too_new" | "theirs_too_old" | "invalid_meta"
    console.error(err.ours)     // { wireVersion, minCompat, writtenBy }
    console.error(err.theirs)   // same + writtenAt
    process.exit(1)
  }
  throw err
}

This is not the same as task payload versioning — it protects taskora's own storage layout against incompatible library upgrades, not your task input schemas. See Upgrading for the full story, including what each code means and the automated tests that make sure a breaking release can't slip through unnoticed.

Default Error Logging

When a task handler throws and no failed listener is registered (neither task.on("failed") nor taskora.on("task:failed")), taskora logs the error to console.error automatically:

[taskora] task "send-email" job a1b2c3d4 failed (attempt 1/3, will retry)
Error: Connection refused
    at handler (/app/tasks/email.ts:15:11)
    at Array.handlerMw (/app/node_modules/taskora/src/worker.ts:82:36)
    ...

This fires synchronously on the worker that processed the job — no Redis round-trip, full stack trace preserved.

The default logger is automatically suppressed the moment you register your own failed listener:

ts
// Default logging stops as soon as you add this:
taskora.on("task:failed", (event) => {
  myLogger.error({ task: event.task, jobId: event.id, error: event.error })
})

Per-task listeners also suppress it for that specific task:

ts
sendEmailTask.on("failed", (event) => { /* custom handling */ })
// Default logger no longer fires for send-email, but still fires for other tasks

Error Flow

ErrorWhenRetried?
ValidationErrorSchema validation failsNo
RetryErrorctx.retry() calledAlways (if attempts remain)
TimeoutErrorHandler exceeds timeoutNo (opt-in via retryOn)
CancelledErrorhandle.cancel() calledNo
ThrottledErrorThrottle rejects dispatchN/A (dispatch-time)
DuplicateJobErrorDedup rejects dispatchN/A (dispatch-time)
ExpiredErrorTTL expiresNo
StalledErrormaxStalledCount exceededNo
Any other ErrorHandler throwsYes (if attempts remain)

Released under the MIT License.