Exceptions¶
taskq.exceptions is the single home for the classes the library raises on
the job surface — the errors application code meets in ordinary operation
(the tables below). Operator machinery keeps its own typed errors beside its
code, not here: taskq.migrate's MigrationLockTimeoutError /
ChecksumDriftError (the CLI surfaces them as SystemExit), the worker
health probes' bind errors, OtelExporterConfigurationError,
TimescaleDBUnavailableError. None of them is a job-operation error.
Import errors from it (or from taskq for the names the package re-exports;
the Package Overview shows which) and catch TaskQError as the
fallback in handlers that must never let a library error escape.
Every error's full contract — fields, message shape, and the reasoning behind the classification — renders under the module directive below; this page's tables are the when/why/what-to-do index.
Consumer-facing taxonomy¶
The errors application code meets in ordinary operation. Ordered roughly by the surface that raises them: enqueue-time, then result-reading, then infrastructure.
| Exception | Raised when | Why this class | What to do |
|---|---|---|---|
MaxPendingExceededError |
enqueue() on an actor whose pending + scheduled count is at its max_pending cap. |
Capacity is exhausted; the library never blocks on it. | Retry later, shed load, or inside an actor body raise Snooze instead of letting this reach the retry classifier. |
MaxPendingLockTimeoutError |
The advisory-lock wait bounding a capped actor's exact count-then-insert expired its budget: the cap check never ran. | A BackpressureError sibling of MaxPendingExceededError — same response, but nothing was counted. |
Retry later or shed load, exactly as for a cap rejection. Fields: actor, timeout_ms. |
UniqueForLockTimeoutError |
A unique_for enqueue's single-flight advisory lock wait expired: the dedup answer was not determinable in time. Nothing was inserted. |
Deliberately NOT a BackpressureError — nothing about capacity is wrong, so load-shedding handlers must not misfire. |
Retry the SAME enqueue: by then the winner's row is typically committed and the retry dedupes against it. |
IdempotencyKeyLockTimeoutError |
The bounded wait for an idempotency-token INSERT expired: the dedup answer for one (idempotency_scope, idempotency_key) pair was not determinable in time. Nothing was inserted. |
Third member of the enqueue-serialization family (with the two lock timeouts above); takes UniqueForLockTimeoutError's treatment. |
Retry the same enqueue: once the holder's transaction resolves, the retry dedupes or inserts fresh. |
SingletonCollisionError |
A singleton actor already has a job in pending/scheduled/running. |
Enqueue-time backpressure for the singleton discipline. | Fields: blocking_job_id (the UUID of the existing job), retry_after (from its schedule_to_close, may be None). |
BatchMaxPendingExceededError |
A bulk enqueue partitioned admission per actor and refused some: within-cap actors' items were inserted FIRST, then this raised. | The bulk-tier sibling of PartialBatchError: partial admission is typed so a blind whole-batch retry cannot silently duplicate. |
Retry only the refused indices (refusals, refused_indices, admitted_count) or give items idempotency_keys. |
PartialBatchError |
An autonomous ctx.jobs.enqueue_batch() partially failed: items enqueued before the first failure are committed, the rest are not inserted. |
The house shape for partial batch admission: succeeded count + failed indices + typed per-failure exceptions. | Fields: succeeded_count, failed_items (index → exception), total. Retry only the failed indices. |
SubEnqueueError |
flush_buffer() failed for one or more buffered sub-job enqueues AFTER the parent job committed and was marked succeeded: child jobs were lost. |
The parent's success is already durable; this error must be loud, not silent. | Fields: failed_items (each failed EnqueueArgs + its exception). Re-enqueue the lost children. |
UnknownQueueError |
With TASKQ_QUEUES_STRICT=true: a submit whose queue is declared in neither the fleet's registered assignments nor the process's own queue set (at the submit door, nothing stored), or a worker boot whose configured TASKQ_QUEUES contains a queue nothing routes to. |
Silent message loss is the worst queueing failure class; strict converts it into a loud refusal at the door. The asymmetry is the spine: refusals require positive two-source evidence; every ambiguous state (empty snapshot, read failure, env-vs-snapshot disagreement, the TTL window) resolves to ALLOW + advisory — over-rejection is strictly worse than over-permission. | Fix the queue name, register an actor on it, pass allow_unregistered=True for a genuinely dynamic name, or disable the knob. Safe deploy order for new queues: migrate → workers → clients; a client with no declared queue set judges on the snapshot alone (the message says so), so workers-first protects the first submits. |
PayloadValidationError |
Pydantic validation of a payload failed at enqueue ('fail at the door') or dispatch. | Non-retryable in both cases regardless of retry policy. | Fix the payload. Fields: actor, payload_schema_ver, validation_errors, item_index. |
IdempotencyKeyActorMismatchError |
An idempotency hit resolved to a job of a DIFFERENT actor. | A cross-actor hit is not a dedup: the caller would get another actor's result, indistinguishable from success. | Namespace keys per actor or give the actors different idempotency_scopes. Nothing was enqueued. |
JobFailed |
JobHandle.wait() observed a non-success terminal state. |
Carries the row so callers can inspect status/error_class/error_message. |
Inspect exc.row; distinct from ResultUnavailable. |
ResultUnavailable |
JobHandle.wait() observed a terminal state but no usable result (TTL expired, actor returned None for non-None R, or none stored). |
Distinguishes "terminal, no result" from "failed". | Read exc.reason ("result_ttl_expired" / "not_stored"); re-run the work if the result matters. |
CorruptJobDataError |
A stored jsonb column decoded to something its boundary refuses (invalid JSON, or a non-object in a row-contract dict field). | Non-retryable by construction: the bytes on disk cannot change by re-reading them. The dispatch claim boundary fails the row terminally and moves on. | Investigate the row (field: column) — a hand-corrupted row, schema drift, or a foreign writer. |
UnencodableValue |
A value no UTF-8 JSON encoding accepts (the canonical case: a lone surrogate like "\udcff", exactly what os.fsdecode of a non-UTF-8 filename byte yields). |
Subclasses TypeError deliberately, keeping the historical orjson contract: an except TypeError swallows it — write handlers accordingly. |
Non-retryable wherever the producer already ran; fix the value at the source. |
SchemaNotMigratedError |
The backend raised UndefinedTableError: the TaskQ schema is missing. |
Translated from raw asyncpg so operators see an actionable message (the original is chained via __cause__). |
Run taskq migrate up from a pre-deploy job; workers never self-migrate. |
StreamUnavailable |
A TaskQ.stream() / progress_stream() transport could not re-read its job row for at least its failure budget — the Postgres poll transport's fetches, and on the Redis path, the safety-net re-reads behind the subscription. |
A blip shorter than the budget is retried silently; this is the END of the stream — the caller's async for cannot wait forever on a database that is not coming back. |
Handle the stream ending; the last failure is chained as __cause__. |
RateLimitDependencyUnavailable |
A rate limiter's PG store was never wired (no pool injected) at acquire time. | Subclasses RuntimeError deliberately (the historical contract); the acquire boundary fails CLOSED — a non-consuming denial, never a burnt attempt. |
Fix the wiring: inject the pool the limiter's fallback needs. |
RateLimitStoreCorrupt |
A limiter store answered, but the reply violates the reply contract (a token count outside [0, capacity], a malformed TIME tuple, ...). |
A store that LIES is failed closed exactly like a store that cannot serve, and re-runs admission against the durable PG row. | Treat as a store outage; investigate the store's integrity. |
ActorDeregistrationError |
Base for actor deregistration refusals. | Lets cleanup code catch every refusal shape with one clause. | See the Actor API: Actor deregistration. |
ActorHasActiveJobsError |
Non-terminal jobs reference the actor being deregistered. | Carries active_count and per-status status_counts so the caller can decide: cancel first, or force=True (which still refuses on RUNNING jobs). |
Cancel the blocking jobs, or pass force=True for pending/scheduled ones and wait out the running. |
ActorHasEnabledSchedulesError |
Enabled cron schedules reference the actor being deregistered. | Carries schedule_ids so the caller can disable or delete them first. |
Disable/delete the named schedules, or pass force=True to disable them automatically. |
ActorNotFoundError |
Deregistering an actor with no stored actor_config row. |
Lets idempotent cleanup loops treat "already gone" as success. | Catch and continue. |
TaskQError |
— (base class). | The catch-all for every library-raised error EXCEPT the deliberate builtin-base escapees: UnencodableValue (a TypeError) and RateLimitDependencyUnavailable + RateLimitStoreCorrupt (a RuntimeError). |
An except TaskQError handler misses exactly those three; add explicit clauses if they matter to you. |
Control-flow signals Snooze and RetryAfter also live in this module but
are NOT errors — they are signals an actor raises to translate into state
transitions; see Actors: Control-flow exceptions
and Retries.
The five names outside the package __all__
CorruptJobDataError, UnencodableValue, IdempotencyKeyLockTimeoutError,
RateLimitDependencyUnavailable, and RateLimitStoreCorrupt are public
here in taskq.exceptions but deliberately not re-exported from
taskq.__all__ — the export surface is frozen pending a separate
decision. Import them from taskq.exceptions (as this page does); every
other name in the table above resolves from both homes.
Module reference¶
exceptions ¶
Exception hierarchy for TaskQ.
Mirrors: control-flow exceptions like Snooze and RetryAfter are not errors, they are signals the consumer translates into state transitions.
TaskQError ¶
Bases: Exception
Base for all library-raised exceptions.
JobFailed ¶
Bases: TaskQError
:meth:JobHandle.wait saw a non-success terminal state.
Carries the row so callers can inspect status, error_class,
error_message, and error_traceback. Distinct from
:class:ResultUnavailable (which means terminal but no result
stored) and from the original actor exception (which is recorded on
the row, not raised).
Source code in src/taskq/exceptions.py
ResultUnavailable ¶
Bases: TaskQError
:meth:JobHandle.wait saw a terminal state but no usable result.
Causes:
- result TTL expired before the call;
- actor returned None while R is non-None
(treated as schema mismatch, not a value);
- row stored result=NULL for a non-success status.
reason distinguishes the two live-result causes at the instance:
"result_ttl_expired" when the row itself carries the expiry
evidence (result_expires_at in the past, which the retention
sweep leaves in place on the rows it nulls). The stamp proves the
TTL elapsed, no more: the completion write stamps
result_expires_at even when the result itself is NULL (an
actor that returned None) and the sweep only nulls rows whose
result was still stored, so a past stamp cannot distinguish a swept
result from a row that never stored one. The message asserts only
what the row proves. "not_stored" covers every other case.
Carries the row for inspection.
Source code in src/taskq/exceptions.py
EventRetentionGapError ¶
Bases: TaskQError
A watch_reclaims stream resumed a cursor the retention horizon passed.
The event-prune watermark (job_events_prune_state.pruned_through_id,
migration 01.00.20_02) records the highest job_events id any retention
deleter has committed a delete below-or-at. A resumed cursor strictly
below that bound means at least one event id the consumer never received
was deleted: the hole can never be refilled, so the stream ends here
instead of silently skipping to live and letting the consumer believe it
saw everything.
This makes a loss loud that silent polling would hide:
no error is raised on either side, the poll simply returns the
surviving rows and the consumer's async for read like a quiet fleet.
The signal is conservative by design: the watermark is a union bound across event kinds, so it proves the feed is incomplete without proving which slice a consumer filters for lost a row. A cursor AT or above the watermark never raises: every deleted id was at or below a position the consumer had already been delivered.
Attributes:
| Name | Type | Description |
|---|---|---|
cursor |
the resumed cursor the poll rejected. |
|
pruned_through_id |
the watermark it sits behind. |
Remediation: reposition the consumer past the watermark (accepting the
recorded loss) or size event_retention_period against the slowest
consumer's worst outage so the horizon cannot pass a live cursor.
Source code in src/taskq/exceptions.py
StreamUnavailable ¶
StreamUnavailable(
job_id: JobId,
*,
consecutive_failures: int,
elapsed: float,
last_error: BaseException,
)
Bases: TaskQError
A job stream could not re-read its job row for at least its failure budget.
Raised by either transport behind :meth:TaskQ.stream and
:meth:JobHandle.progress_stream — the Postgres poll transport's
fetches, and on the Redis path, the safety-net re-reads behind the
subscription — when every fetch has failed with a pool or connection
error for elapsed seconds (the transport's failure budget). A
blip shorter than the budget is retried silently beyond a warning
per failed poll; this is the end of the stream, so the caller's
async for cannot wait forever on a database that is not coming
back. The last failure is chained as __cause__.
Source code in src/taskq/exceptions.py
BackpressureError ¶
Bases: TaskQError
Base class for synchronous enqueue-time backpressure signals.
Subclassed by SingletonCollisionError (singleton collision) and used directly by max_pending enforcement. The caller decides whether to retry, fail, or wait; the library does not block on capacity.
Source code in src/taskq/exceptions.py
SingletonCollisionError ¶
SingletonCollisionError(
actor: str,
blocking_job_id: UUID | None = None,
retry_after: timedelta | None = None,
)
Bases: BackpressureError
Raised when a singleton actor already has a job in pending/scheduled/running.
blocking_job_id is the UUID of the existing job from the Layer 1
pre-flight query; it is None when raised from the Layer 2
UniqueViolationError catch (the race path) because no pre-flight row
was fetched.
retry_after is computed from the blocking job's schedule_to_close
when available. It is None when the blocking job has no
schedule_to_close set, or when raised from the Layer 2 catch path.
The heartbeat_interval * 4 fallback is intentionally NOT
implemented, retry_after is computed from schedule_to_close only.
Callers who need a poll cadence when retry_after is None should poll on
their own schedule (research.md Gap 1, resolution path (a)). Reason:
heartbeat_interval is not available at the backend enqueue boundary;
propagating it would require enlarging the backend constructor surface and
is out of scope.
Source code in src/taskq/exceptions.py
MaxPendingExceededError ¶
Bases: BackpressureError
Raised when an actor's max_pending queue-depth limit is reached.
current_count is the count of pending+scheduled jobs at the time
of the pre-flight check. max_pending is the configured limit.
The caller decides whether to retry, fail, or wait; the library does
not block on capacity.
Source code in src/taskq/exceptions.py
hint
class-attribute
instance-attribute
¶
hint = "Inside an actor body, prefer `raise Snooze(delay)` over letting this reach the retry classifier: a full queue is backpressure, not a failure, and a Snooze defers without spending the job's retry budget."
MaxPendingLockTimeoutError ¶
Bases: BackpressureError
Raised when the advisory-lock wait bounding a capped actor's exact count-then-insert exceeded its budget.
Distinct from :class:MaxPendingExceededError: the cap was never
observed -- this enqueue lost the race to even run the check, waiting
behind other producers on the same (schema, actor) lock until the
budget expired. The caller's correct response is the same as for a cap
rejection (retry later, or shed load), so this is raised from the same
:class:BackpressureError family and recorded against the same
taskq.backpressure.errors counter (kind="max_pending_lock_timeout").
timeout_ms is the budget that expired. pending is 0 and
max_pending is None -- no count was taken.
Deliberately NOT a subclass of :class:MaxPendingExceededError: that
class means "the cap is full" and carries the observed count; a lock
timeout means "too contended to check" and conflating the two would
mislead handlers that react to a full queue (e.g. by logging the
count). Catch :class:BackpressureError to treat both the same way.
Source code in src/taskq/exceptions.py
args
instance-attribute
¶
args = (
f"backpressure: enqueue for actor {actor!r} could not acquire the max_pending advisory lock within {timeout_ms:g} ms of contention (the exact cap check was not reached). Retry later or shed load, exactly as for MaxPendingExceededError.",
)
UniqueForLockTimeoutError ¶
Bases: TaskQError
Raised when the advisory-lock wait bounding a unique_for
enqueue's preflight-then-insert exceeded its budget.
Distinct from :class:MaxPendingLockTimeoutError on purpose. That
error is a :class:BackpressureError: the caller's own load filled
the contention scope (every producer of a capped actor), the cap
check never ran, and the correct response is the same as for a cap
rejection, retry later or shed load. This error means the DEDUP
ANSWER for one (schema, actor, identity_key) could not be
determined in time: the contention scope is a single logical
entity's identity (a same-key stampede, or a black-holed holder the
server has not yet reaped), nothing about capacity is wrong, and the
correct response is to RETRY THE SAME ENQUEUE, by then the winner's
row is typically committed and the preflight returns it as a dedup
hit, which is the very outcome the wait existed to produce.
Deliberately NOT a :class:BackpressureError so handlers that react
to backpressure by shedding load or logging queue counts cannot
misreact, and deliberately NOT recorded against the
taskq.backpressure.errors counter (identity-key contention is
not a capacity signal; the unique-for-lock-timeout log event
carries the observability instead).
identity_key names the contended entity. timeout_ms is the
budget that expired. This enqueue wrote nothing: on a pool-owned
transaction the loser's transaction rolled back before any write; on
a caller-owned transaction (enqueue_with_conn /
TaskQ.with_conn) the savepoint the bounded acquire used rolled
back, the transaction remains usable, and durability of anything the
CALLER wrote alongside is the caller's decision, not this error's
claim to make.
Source code in src/taskq/exceptions.py
IdempotencyKeyLockTimeoutError ¶
IdempotencyKeyLockTimeoutError(
actor: str | None,
idempotency_key: str,
timeout_ms: float,
*,
idempotency_scope: str | None = None,
)
Bases: TaskQError
Raised when the bounded wait for an idempotency token insert exceeded its budget.
The third member of the enqueue serialization family, after
:class:MaxPendingLockTimeoutError (capacity) and
:class:UniqueForLockTimeoutError (single-flight identity): the
speculative ON CONFLICT (idempotency_scope, idempotency_key) DO
NOTHING token INSERT blocks on another transaction's UNCOMMITTED
same-pair row, Postgres must wait for that transaction's uniqueness
verdict, and on a transactional consumer the holder IS the actor's
own open transaction, whose runtime is unbounded by default
(default_start_to_close = None). The wait is bounded by a
lock_timeout scoped to the INSERT's savepoint; on expiry the
DEDUP ANSWER for that one (idempotency_scope, idempotency_key)
pair could not be determined in time, which is
:class:UniqueForLockTimeoutError's exact situation and therefore
takes its treatment: retry the same enqueue, once the holder's
transaction resolves, the retry either dedupes against the committed
token or inserts fresh. Deliberately NOT a
:class:BackpressureError (nothing about capacity is wrong) and
deliberately NOT recorded against taskq.backpressure.errors;
the idempotency-lock-timeout log event carries the
observability instead.
This enqueue wrote nothing: the savepoint that carried the GUC and the INSERT rolled back first, so the caller's transaction remains usable, the same caller-owned-transaction discipline the singleton collision's savepoint established.
Source code in src/taskq/exceptions.py
IdempotencyKeyActorMismatchError ¶
IdempotencyKeyActorMismatchError(
*,
actor: str,
existing_actor: str,
existing_job_id: UUID | None,
idempotency_key: str,
idempotency_scope: str | None,
)
Bases: TaskQError
An idempotency hit resolved to a job of a DIFFERENT actor.
Uniqueness is (idempotency_scope, idempotency_key), schema-wide, so
two actors sharing a key collide. A same-actor hit is a dedup and returns
the existing row; a cross-actor hit cannot be one, the caller asked for
THIS actor's job and would receive a handle whose result is another
actor's, indistinguishable from a successful dedup, so it is refused.
The composite index here cannot include the actor without a migration,
so the hit is checked after the fact and refused instead of silently
resolved.
Nothing was inserted (single enqueue: the arbiter skipped the row; batch:
the whole batch is rolled back, all-or-nothing like a singleton
collision; batch fast: the COPY aborts the whole batch the same way).
Namespace keys per actor ("send_receipt:order_123") or
give the two actors different idempotency_scope values.
existing_job_id names the stored job the key matched; it is None
when the collision is between two items of the same fast-path batch,
where no row was stored for either.
Source code in src/taskq/exceptions.py
BatchMaxPendingExceededError ¶
BatchMaxPendingExceededError(
*,
refusals: list[MaxPendingExceededError],
refused_indices: dict[str, list[int]],
admitted_count: int,
)
Bases: BackpressureError
A bulk enqueue partitioned its admission per actor and refused some.
Raised by :meth:~taskq.backend._protocol.Backend.enqueue_batch /
:meth:~taskq.backend._protocol.Backend.enqueue_batch_fast (and every
client path riding them) when one or more actors' items exceed their
effective max_pending: the within-cap actors' items are inserted
FIRST, then this error raises naming the refusals, the bulk-tier
sibling of :class:PartialBatchError, which is the house shape for
partial batch admission (succeeded count + failed indices + typed
per-failure exceptions).
An over-cap actor's items are refused as a whole group, never partially filled up to the cap: the single-enqueue path refuses a capped enqueue outright, and a partial fill would admit an arbitrary prefix of the caller's items that the caller never chose.
Fields:
refusals, one :class:MaxPendingExceededErrorper over-cap actor (actor,current_countat the admission check, the effectivemax_pending).refused_indices, actor name -> indices into the caller's items list of that actor's refused items. For :meth:~taskq.client.JobsClient.enqueue_batch_streaming's chunked path the indices are stream-global and the stream stops at the refusing chunk: items after it were never attempted.admitted_count, how many items were admitted and inserted by the raising call.
Durability of the admitted items depends on the path: committed when
the call owned its transaction (no caller-supplied connection, one
pool transaction per call/chunk); inserted-but-uncommitted on a
caller-supplied connection with an open transaction, where that
transaction's commit/rollback decides. enqueue_batch /
enqueue_batch_streaming with failure_policy or finalizer
and no connection (the atomic path) never raises this error, its
single transaction keeps the legacy all-or-nothing contract and
raises plain :class:MaxPendingExceededError with nothing committed.
Deliberately NOT a :class:MaxPendingExceededError subclass: handlers
written for the pre-partition contract assume that a raised
MaxPendingExceededError left nothing enqueued. Under this error
part of the batch IS stored, a blind whole-batch retry would
duplicate the admitted items. Catch this type explicitly and retry
only the refused indices, or give items idempotency_keys so a
whole-batch retry deduplicates against the admitted rows.
The same hazard reaches handlers written against the shared base:
except BackpressureError catches this error too, and the
base's contract ("the caller decides whether to retry, fail, or
wait") predates partial admission. A generic backpressure handler
that retries the whole batch MUST first consult
admitted_count / refused_indices, retry only the refused
items, or rely on idempotency_keys, otherwise it duplicates
the admitted items on every retry.
Source code in src/taskq/exceptions.py
PayloadValidationError ¶
PayloadValidationError(
detail: str,
*,
actor: str | None = None,
payload_schema_ver: str | None = None,
validation_errors: list[dict[str, object]]
| None = None,
item_index: int | None = None,
)
Bases: TaskQError
Pydantic validation failed at enqueue or dispatch.
At enqueue: raised before the row is inserted ('fail at the door'). At dispatch: causes the job to transition to 'failed' with error_class='PayloadValidationError'. Non-retryable in both cases regardless of the actor's retry policy.
item_index is the failing item's position in the CALLER's
coordinate space (the batch list / the streaming caller's stream)
whenever the raise site knows one, batch per-item guards and their
remapping boundaries populate it, so a handler or retry tool reads
the position as a field instead of parsing the message. None
is the no-coordinate case: single-item enqueue, dispatch-time
validation, non-itemized configuration errors. The message embeds
the same index for humans; the field is the machine-readable copy.
Source code in src/taskq/exceptions.py
CorruptJobDataError ¶
Bases: TaskQError
A stored jsonb column decoded to something its boundary refuses.
Two shapes trigger it, both classified at the decode boundary:
- Text that is not valid JSON at all (a hand-corrupted row, schema
drift to
text, an interop writer), through :func:taskq.backend._records.jsonb_to_dictOR its user-content sibling :func:taskq.backend._records.jsonb_to_value. - Valid JSON whose body is not the object the row contract declares
(a list or scalar in a dict-typed field, e.g. a row written by a
different version's codec), through :func:
jsonb_to_dictonly. That shape gate covers the ROW-CONTRACT dict fields TaskQ itself owns and indexes into (metadata,progress_state, the rate-limitstate,detail). The USER-CONTENT columns (result,payload) hold the actor's data and declare no shape, so they decode through :func:jsonb_to_valuewith ONLY the malformed-text guard: a valid list or scalar round-trips.
The raw driver/JSON errors (orjson.JSONDecodeError, a
ValueError) are re-raised as this class so every consumer sees
one named error_class instead of a driver vocabulary word.
Non-retryable by construction: the bytes on disk cannot change by
re-reading them. The dispatch claim boundary
(:mod:taskq.backend._dispatch) catches this class per claimed row,
writes the row's terminal failure with
error_class='CorruptJobDataError' through the same fused
mark_failed statement every other failure uses, and dispatches
the round's remaining healthy rows, so one poisoned row can neither
crash the worker through the producer loop's unexpected-failure
backstop nor loop forever through claim and lease-sweep reclaim.
Source code in src/taskq/exceptions.py
ResultTooLarge ¶
Bases: TaskQError
Terminal result exceeded WorkerSettings.result_max_bytes.
Non-retryable: the actor already ran to completion and a re-run returns
the same oversized value, so retrying only burns the remaining attempts
(re-running the actor's side effects each time) before the job fails
anyway. Classified alongside PayloadValidationError in
:meth:taskq.retry.RetryClassifier.classify.
UnencodableValue ¶
Bases: TypeError
A value no UTF-8 JSON encoding accepts, so no PG text/jsonb
form of it exists.
The canonical case is a lone surrogate ("\udcff", exactly what
os.fsdecode of a non-UTF-8 filename byte yields): a legal Python
str that orjson refuses to encode. Non-str dict keys and
objects the fallback cannot convert raise the same class. Raised by
:func:taskq._json.dumps, the single serialization boundary, so
every producer sees one class; subclassing :class:TypeError keeps
the historical orjson contract every except TypeError caller and
wording pin already relies on.
Non-retryable wherever the producer already ran (an actor result): a
re-run reproduces the same unencodable value, so retrying only burns
the remaining attempts, the exact burn :class:ResultTooLarge
exists to prevent. Classified alongside it in
:meth:taskq.retry.RetryClassifier.classify. The durable-write
boundary (:func:taskq._json.dumps_jsonb_str) instead escapes the
unencodable codepoints, mirroring :func:taskq._json.sanitize_nul_str:
rejecting there would strand the very work the value describes.
ProgressTooLarge ¶
Bases: TaskQError
Raised when progress data payload exceeds the configured size limit.
limit is the configured cap in bytes (WorkerSettings.progress_data_max_bytes).
actual is the serialised byte length of the data dict that was rejected.
Non-retryable: the caller must reduce the payload before retrying.
Source code in src/taskq/exceptions.py
ScopeViolation ¶
Bases: TaskQError
A provider depends on a shorter-lived scope than its own.
Source code in src/taskq/exceptions.py
DependencyCycle ¶
Bases: TaskQError
A cycle was detected in the provider graph.
Source code in src/taskq/exceptions.py
MissingProvider ¶
Bases: TaskQError
A type was injected but no provider is registered.
Source code in src/taskq/exceptions.py
DIError ¶
Bases: TaskQError
Base for DI engine errors not covered by startup-validation.
Raised by the solver at resolution time for malformed annotations (e.g. multiple Scope markers in one Annotated parameter) or unresolvable forward references in actor signatures. Distinct from MissingProvider / ScopeViolation / DependencyCycle, which are raised at startup validation.
Snooze ¶
Bases: TaskQError
Job returns control with new scheduled_at; does not consume retry budget.
Source code in src/taskq/exceptions.py
RetryAfter ¶
Bases: TaskQError
Schedule retry at specific delay. Consumes retry budget by default.
Source code in src/taskq/exceptions.py
ReservationUnavailable ¶
ReservationUnavailable(
bucket_name: str,
retry_after: timedelta,
*,
source: Literal[
"reservation", "rate_limit"
] = "reservation",
)
Bases: TaskQError
A ConcurrencyReservation slot could not be acquired.
When the upstream RateLimitDecision.retry_after is None, callers
MUST substitute DEFAULT_RESERVATION_BACKOFF. When it is
timedelta(0) (allowed decisions) callers MUST pass it through
unchanged, do NOT use a truthiness coalesce
(x or DEFAULT_RESERVATION_BACKOFF) because timedelta(0) is falsy
and would be wrongly replaced.
Source code in src/taskq/exceptions.py
RateLimitDependencyUnavailable ¶
Bases: RuntimeError
A rate limiter's PG store was never wired, no pool was injected.
Raised by every ratelimit PG delegate's no-pool branch (the token
bucket's acquire/peek/reset/refund and both sliding-window styles)
when the delegate is reached with pg_pool=None: most often the
Redis→PG fallback funnelling into a fallback pool the caller never
injected, but a directly PG-backed limiter with no pool is the same
condition. The store dependency cannot answer, so the acquire
boundary's correct response is the limiter's fail-closed denial ,
:data:taskq.worker._consumer._RATE_LIMIT_DEPENDENCY_EXCEPTIONS
includes this class for exactly that; an escapee would instead be
misattributed to the job as a failure (a retry attempt burnt and a
wiring gap persisted as the job's error_class).
Deliberately NOT a :class:TaskQError: subclassing
:class:RuntimeError keeps the historical contract every existing
caller and wording pin relies on, the except RuntimeError /
pytest.raises(RuntimeError, match="pg_pool not injected...") pins
and the chaos tier's fail-loud pytest.raises(RuntimeError) all
hold unchanged (the same builtin-base precedent as
:class:UnencodableValue).
RateLimitStoreCorrupt ¶
Bases: RateLimitDependencyUnavailable
A limiter store answered, but the reply violates the reply contract.
The trust-boundary verdict for a store that LIES: a semantically
wrong reply (a value outside the reply's type or range domain, a
truncated or wrongly-shaped payload) is indistinguishable from a
store that cannot serve, so it is failed closed as exactly that.
Raised by the Redis reply validators (the token-bucket and
sliding-window Lua decoders, the peek-path parsers, the store-clock
read) when a reply carries a value no honest script could produce:
a token count outside [0, capacity], a negative token count, a
non-finite or negative retry hint, a window count outside
[0, limit], a retry hint past the key's own TTL, a malformed
TIME tuple.
Subclassing :class:RateLimitDependencyUnavailable is the routing
mechanism, not a convenience: the acquire boundary's
_RATE_LIMIT_DEPENDENCY_EXCEPTIONS family already includes the
parent, so a lie synthesizes the same non-consuming fail-closed
denial an outage gets (never a burnt attempt, never the lie's value
persisted as the job's error), and :func:taskq.ratelimit
._redis_utils.with_pg_fallback names this class explicitly beside
the outage exceptions so a suspected lie re-runs admission against
the durable PG row, the same fallback path an outage takes.
IllegalStateTransition ¶
Bases: TaskQError
Attempted to transition a job to a status not reachable from its current status.
Best-effort fast-path check only; the SQL WHERE clause is the authoritative serialization gate for concurrent writes.
Source code in src/taskq/exceptions.py
WorkerOwnershipMismatch ¶
Bases: TaskQError
Terminal write predicate failed: job exists but is owned by a different worker.
Source code in src/taskq/exceptions.py
ActorConfigDriftError ¶
ActorConfigDriftError(
actor: str,
field: Literal["metadata"],
registered: dict[str, object] | None,
stored: dict[str, object] | None,
)
Bases: TaskQError
One actor whose registered structural config differs from the stored row.
Only metadata is structural, no operator surface can move it, so a
mismatch there is always a correctness bug and refuses boot. The queue
assignment is operator-owned once a row exists (moved by
taskq actor-config move-queue): a differing literal never raises,
it logs actor-config-queue-override at boot and the stored
assignment wins. Capacity fields (max_concurrent,
max_pending, result_ttl) are likewise operator-owned and never
raise this error; see :func:taskq.worker.startup.sync_actor_config.
Source code in src/taskq/exceptions.py
ActorConfigDriftList ¶
Bases: TaskQError
Collected wrapper raised at worker startup when one or more actors have drift.
Source code in src/taskq/exceptions.py
ActorDeregistrationError ¶
ActorHasActiveJobsError ¶
ActorHasActiveJobsError(
actor: str,
active_count: int,
status_counts: dict[str, int],
*,
force: bool = False,
)
Bases: ActorDeregistrationError
Non-terminal jobs reference the actor.
Carries the count and per-status breakdown of the blocking jobs so the
caller can decide whether to cancel them first or use force=True.
When force is True, the message reflects that running jobs
cannot be cancelled by force=True, the caller must wait for
them to finish or cancel them individually first.
Source code in src/taskq/exceptions.py
ActorHasEnabledSchedulesError ¶
Bases: ActorDeregistrationError
Enabled cron schedules reference the actor.
Carries the schedule IDs so the caller can disable or delete them first.
Source code in src/taskq/exceptions.py
ActorNotFoundError ¶
Bases: ActorDeregistrationError
The actor_config row does not exist, nothing to deregister.
Currently raised only by :func:deregister_actor. Other ops
(get, set_capacity) return None for missing rows.
Source code in src/taskq/exceptions.py
PartialBatchError ¶
PartialBatchError(
*,
succeeded_count: int,
failed_items: list[tuple[int, Exception]],
total: int,
)
Bases: TaskQError
Raised when an autonomous enqueue_batch partially fails.
Items enqueued before the first failure are committed; remaining
items are not inserted. succeeded_count is the number of items
that were successfully enqueued. failed_items maps the index
of each failed item to its exception. total is the original
batch size.
Source code in src/taskq/exceptions.py
SchemaNotMigratedError ¶
Bases: TaskQError
Backend raised UndefinedTableError, the TaskQ schema is missing.
Translated by the client layer (:mod:taskq.client._jobs) from an
asyncpg.exceptions.UndefinedTableError on the enqueue/get/list/cancel
paths, so operators see an actionable message instead of a raw asyncpg
traceback. The original exception is chained via __cause__.
Source code in src/taskq/exceptions.py
EmptyFilterError ¶
Bases: TaskQError
Raised when cancel_where is called with a filter that has no predicates.
A filter with no queue, status, actor, identity_key, batch_id, tags, or
active predicate would match every job in the table, almost certainly
a bug. The guardrail is intentionally loud: the caller must add at least
one predicate or explicitly bypass with allow_empty_filter=True.
Source code in src/taskq/exceptions.py
ScopedIdempotencyMigrationPendingError ¶
ScopedIdempotencyMigrationPendingError(
*,
actor: str | None = None,
idempotency_key: str | None = None,
idempotency_scope: str | None = None,
detail: str | None = None,
)
Bases: TaskQError
idempotency_scope was used, but the schema has not yet had
01.00.03_01_post_idempotency_scope_drop_old_index.sql applied.
Between applying 01.00.03_01_pre_idempotency_scope.sql and its
post counterpart (the rolling-deploy window every worker's schema
passes through), BOTH the old global jobs_idempotency_key_uniq
index (on idempotency_key alone) and the new composite
jobs_idempotency_scope_key_uniq index (on (idempotency_scope,
idempotency_key)) exist simultaneously, this is deliberate, see the
"PHASE OBLIGATIONS" comment in the pre migration file, and is what
keeps pre-this-release code's unscoped ON CONFLICT (idempotency_key)
working unmodified during the window.
The cost of that safety: enqueuing the same idempotency_key under
two different idempotency_scope values satisfies the new
composite index's ON CONFLICT target (no conflict there, the
(scope, key) pair is new) but still violates the still-present old
global index, which is not covered by that ON CONFLICT target.
PostgreSQL raises UniqueViolationError for a conflict against a
non-arbiter unique index unconditionally, the library deliberately
does NOT catch that and silently fall back to a different scope's row,
because doing so would return the wrong job for the scope the caller
actually asked for, silently, which is a worse failure mode than a
loud, explicit error for a purely transitional migration-window
condition. Raised instead of letting the raw
asyncpg.UniqueViolationError propagate.
Any call, scoped or unscoped, is affected whenever its
idempotency_key already exists under a different scope: an
unscoped call that reuses a key first written under a non-default
scope raises this error just as a scoped call reusing an unscoped
key does (verified against live PostgreSQL). Only brand-new keys and
same-scope repeats are unaffected, a repeated key under the same
scope (including two unscoped calls, which share the default ''
scope) conflicts identically against both indexes for the exact same
row, which ON CONFLICT DO NOTHING on the composite index resolves
cleanly.
Resolution: confirm every worker is running the release that shipped
idempotency_scope, then apply
taskq migrate up --phase post (or a plain taskq migrate up) to
drop the old index and activate scoped dedupe, or avoid passing
idempotency_scope until that migration has run.
Source code in src/taskq/exceptions.py
DuplicateIdempotencyKeyError ¶
DuplicateIdempotencyKeyError(
*,
idempotency_key: str | None = None,
idempotency_scope: str | None = None,
detail: str | None = None,
)
Bases: TaskQError
enqueue_batch_fast aborted: an item's
(idempotency_scope, idempotency_key) pair is already enqueued by the
SAME actor. A pair spanning two actors raises
:class:IdempotencyKeyActorMismatchError instead, the same refusal the
single and batch tiers apply.
COPY has no ON CONFLICT arbiter, so a same-pair duplicate ,
repeated within the batch or raced against a row the composite
jobs_idempotency_scope_key_uniq index already covers, aborts the
ENTIRE batch before a single row is written (all-or-nothing; the
abort is deliberate bulk-import semantics, unchanged by the
classification this error introduced). The non-fast paths never
raise for this condition: their ON CONFLICT arbiter dedupes and
RETURNS the existing row, so a typed domain error for a
deduplication-constraint violation on the enqueue path does not exist
there, hence this class, expressing the same idempotency constraint
at the bulk-import boundary in both the SQL and in-memory backends.
Distinct from :class:ScopedIdempotencyMigrationPendingError, which is
the rolling-deploy window's cross-scope reuse signal.
idempotency_key / idempotency_scope carry the offending pair,
resolved exactly on both backends by one shared rule
(first_duplicate_idempotency_pair): the first item in batch order
whose pair repeats an earlier item or is already stored. The PG path
resolves the stored half with a targeted post-abort lookup inside the
caller's transaction scope, never by parsing the violation's detail
text, which renders values raw and unquoted (ambiguous under
positional reading for comma-bearing scopes, unusable when localized
or truncated). Both fields are None only when the conflicting
row could not be resolved at all, a committed-and-instantly-deleted
racer, where the detail-text match is the last word and still never
guesses. detail carries the postgres detail verbatim when
present.
Resolution: pre-deduplicate the items, or use
:meth:~taskq.client.JobsClient.enqueue_batch, which dedupes and
returns the existing rows.
Source code in src/taskq/exceptions.py
SubEnqueueError ¶
Bases: TaskQError
Raised by flush_buffer() when one or more buffered sub-job enqueues fail after parent commit.
failed_items carries each failed EnqueueArgs and the exception
that caused the enqueue to fail. The parent job has already been
marked succeeded, this exception signals that child jobs were lost.
Source code in src/taskq/exceptions.py
BatchAbortedError ¶
Bases: TaskQError
A batch was aborted because consecutive failures exceeded the threshold.
Running jobs are NOT cancelled by the abort, only pending and scheduled jobs are cancelled. Running jobs continue to completion. This matches the post-terminal-write hook design: the hook runs after the terminal write, so a job that was dispatched before the abort triggered will run to completion.
Source code in src/taskq/exceptions.py
EmptyBatchError ¶
Bases: TaskQError
A batch has fewer jobs than the expected minimum, or is unknown.
This can happen when jobs were pruned before wait_for_batch ran,
when expected_size was set but jobs were never created, or when
wait_for_batch is given a batch_id this client never
enqueued (no jobs and no batches row: the expected=None arm,
whose message names the truth instead of a fabricated minimum).
Pass on_empty="ok" to wait_for_batch to suppress the
no-batch-row variant of this error.
Source code in src/taskq/exceptions.py
BatchIdExistsError ¶
Bases: TaskQError
A caller-supplied batch_id already exists in the batches table.
Raised when :meth:~taskq.client.JobsClient.enqueue_batch or
:meth:~taskq.client.JobsClient.enqueue_batch_streaming is called with
an explicit batch_id that collides with an existing batch row, and
when a member append (the chunked arms, the sub-job enqueuer's
fallback) targets a batch row that already reached a terminal status:
a terminal batch must not gain a member, every counter write guards
status = 'active' so its failure policy would be dead and the
stale-batch sweep (active rows only) could never reconcile. An ACTIVE
row keeps accepting appends (resumption). Appending is a backend-level
rule, so both backends refuse it the same way.
The original asyncpg.UniqueViolationError (PG) is chained via
__cause__ when available.
The two raising situations are distinguishable on the instance:
reason is "exists" for a create-collision (the batch_id names a
row this call did not mean to append to) and "terminal" for a
member append against a row that already finished. The remedy is the
same shape (use a batch_id that names the batch you mean), but a caller
driving resumption-by-append reads reason to tell "stop appending,
this batch is finished" from "you picked an id that is taken".
Source code in src/taskq/exceptions.py
UnknownQueueError ¶
UnknownQueueError(
queues: Sequence[str],
known_queues: Sequence[str],
*,
actor: str | None = None,
source: Literal["submit", "worker_boot"] = "submit",
snapshot_is_ttl_bounded: bool = False,
env_queues: Sequence[str] | None = None,
)
Bases: TaskQError
Raised when strict queue-name validation refuses a queue name.
With TASKQ_QUEUES_STRICT=true the enqueue-time unserved-queue
check (ActorCapacityCache.maybe_warn_unserved_queue — the same
zero-I/O TTL-snapshot verdict every enqueue arm already consults)
becomes HARD: a queue no registered actor's assignment routes to
raises HERE, before any backend write, instead of storing an
orphaned row on a queue no worker claims. Silent data non-delivery
is the worst failure class a queueing library has, and a
one-character typo used to be enough to cause it.
Two raising sites, one class, both naming the offending queue AND the set it was judged against (the unserved-queue note's quality bar: the fix is obvious from the message alone):
- At submit (
source="submit"): the judged sets are TWO — the registered queue assignments (actor_configrows, fleet-wide DB truth the client can read without actor registration — the split-deployment constraint) and, when the process's settings carry one, its own declared set (TASKQ_QUEUES). A refusal requires the queue to miss BOTH: an env-declared but snapshot-absent queue is a new queue mid-deploy and is allowed through the registration lag; an EMPTY snapshot (no worker ever started, the first deploy) carries zero evidence of stranding and never refuses; an UNAVAILABLE snapshot (read failure, capability-less backend) never refuses — a database blip or migration window must not become a submit outage, and the check re-arms on the next refresh. Every ambiguous state resolves to ALLOW + the advisory note; over-rejection is strictly worse than over-permission. The snapshot is TTL-bounded, so a queue registered seconds ago may not be in it yet; when the process declares no queue set the snapshot is the only source and the message says so — deploy workers before clients for new queues, that deploy order is the operator's protection for the first submits. Nothing was stored when this raises — the refusal is at the door. - At worker boot (
source="worker_boot"): the judged set is the worker's own configured consume set (TASKQ_QUEUES), and the coverage is the fleet's stored assignments read back aftersync_actor_config. A configured queue nothing routes to is config drift between deploy and code; the worker refuses to boot. Deliberately worker-only: actors register only in the worker process, so a client-side boot check of this shape would brick split deployments (web submits, worker consumes).
Source code in src/taskq/exceptions.py
1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 | |
env_queues
instance-attribute
¶
__getattr__ ¶
Lazy runtime re-export of validate_actor_payload.
The implementation lives in :mod:taskq._validation (the sanitized
variant: include_url=False, include_input=False and no raw-payload
embedding). It cannot be re-exported at module level here because
taskq._validation imports PayloadValidationError from this
module, a module-level from taskq._validation import ... would be
circular and blow up whenever taskq._validation is imported first.
PEP 562 module __getattr__ resolves the name only when requested,
by which time both modules are fully initialized.