Error Handling¶
A poison pill is a message Turbine can't decode — malformed JSON, a truncated Avro frame, binary garbage where a record was expected (usually a non-conforming producer). Left unhandled, one bad message at a fixed offset would block its partition forever: the worker can't get past it, so nothing after it is ever processed.
The on_error knob on the input topic (kafka.topic(..., on_error=...)) decides
what happens — it governs both an undecodable message and a batch whose
handler raises (see Processing errors below):
on_error |
Behaviour on a bad message / a failing batch |
|---|---|
"fail" (default) |
Stop the whole app cleanly and exit non-zero. |
"skip" |
Drop it, count it, and keep going. |
"dlq" |
Route the raw message(s) to a dead-letter topic, then keep going. |
# Default: halt on the first message we can't decode.
@app.subscribe(kafka.topic("events"), output=kafka.topic("enriched"))
def enrich(batch): ...
# Tolerant: drop undecodable messages and keep the partition moving.
@app.subscribe(kafka.topic("events", on_error="skip"), output=kafka.topic("enriched"))
def enrich(batch): ...
# Dead-letter: keep the raw bad message for inspection / replay.
@app.subscribe(kafka.topic("events", on_error="dlq", dlq="events-dlq"), output=kafka.topic("enriched"))
def enrich(batch): ...
There are three distinct error surfaces, and two knobs:
- Decode (bad bytes — a message that won't parse):
on_error, below. - Processing (the handler raises an exception on an otherwise-decoded
batch): also
on_error— see Processing errors. - Compute (a kernel in your aggregation expressions fails on bad data,
e.g. a divide-by-zero): a separate knob,
on_compute_error.
For the duplicate/ordering guarantees of the messages Turbine can process, see Delivery Guarantees.
on_error="fail" (default) — fail fast¶
A message that can't be decoded stops the app. Turbine logs the offending
record's topic, partition, and offset (so you can find it), and
app.run() raises — the process exits with a non-zero status, distinct
from a clean shutdown (Ctrl-C / SIGTERM / stop_event, which exit 0). An
orchestrator (Kubernetes, systemd, a job runner) sees the failure.
The offset of the bad message is not committed, so restarting without
changing anything re-hits the same message and halts again — by design. This
is fail-fast: "never silently drop data; stop and let a human decide." To get
unstuck, switch the subscription to on_error="skip" and restart (the error
message tells you so), or advance the committed offset past the bad record by
other means. Fixing the upstream producer alone is not enough — the bad message
is already in the topic.
Use fail for pipelines where a message you can't process signals a real
problem you want to know about immediately (financial, audit, correctness-
critical).
on_error="skip" — drop and continue¶
The bad message is dropped, the offset advances past it, and the surviving messages in the same batch are processed normally. Dropping is never silent — every drop produces two signals:
- a count on the
turbine_dlq_messages_totalmetric (labelled by topic, partition, and phase) — always on, never throttled, so it's the signal to alert on; - a
WARNlog with the offending offset, the decode error, and a truncated sample of the payload — throttled to at most one every few seconds per worker, so a misbehaving producer flooding bad data can't flood your logs.
(So yes: skip still logs. It's a WARN, not an ERROR — dropping is a
tolerated, opted-into outcome, not a failure; on_error="fail" is the one that
logs an ERROR and stops.)
Use skip for analytics-tolerant pipelines where a few unparsable messages
out of millions shouldn't take the whole job down.
on_error="dlq" — route to a dead-letter topic¶
Like skip (the partition keeps moving, every record is counted on
turbine_dlq_messages_total and a throttled WARN is logged), but instead of
dropping the bad message it is routed to the DLQ topic (dlq=) you configure,
so you can inspect it and replay it later:
@app.subscribe(kafka.topic("events", on_error="dlq", dlq="events-dlq"), output=kafka.topic("enriched"))
def enrich(batch): ...
Each dead-lettered record keeps:
- the original Kafka key, and the raw payload as the message body — untouched, so you can replay it by producing it straight back to the source topic;
- headers carrying the context:
turbine_dlq_source_topic,turbine_dlq_source_partition,turbine_dlq_source_offset,turbine_dlq_error(the decode error),turbine_dlq_phase, andturbine_dlq_worker. They show up directly inrpk topic consumeand the Redpanda console.
No loss: the dead-letter write is flushed before the input offset commits,
so a crash never advances past a record that wasn't dead-lettered. Under
processing_guarantee="exactly_once" the write rides the same transaction as
the offset commit (no duplicates either); under at-least-once a crash may
re-deliver a DLQ record, deduplicable via the turbine_dlq_source_offset
header.
The dlq topic is created on first write if your broker allows topic
auto-creation; otherwise pre-create it. dlq is required when
on_error="dlq" and rejected otherwise. Both live on the input
topic (kafka.topic(..., on_error="dlq", dlq="events-dlq")).
Use dlq when you need the bad records back — debugging a flaky producer,
audit/compliance, or replay after a fix — rather than just a count.
Processing errors¶
The sections above are about a message Turbine can't decode. The same
on_error knob also governs the next stage: your handler raising an
exception on a batch that decoded fine (a bug, a transient dependency, a
KeyError on an unexpected shape). Left unhandled, that exception used to
restart the worker at the same offset — a crash-loop on a deterministic error.
A handler runs over a whole batch at once (one vectorised call, never per-message), so a processing error isn't tied to a single row. The policy therefore acts at batch granularity:
on_error |
Behaviour when the handler raises |
|---|---|
"fail" (default) |
Stop the app cleanly, exit non-zero, naming the batch's offset range and the handler error. |
"skip" |
Drop the whole batch, advance past it, count it on turbine_dlq_messages_total{phase="processing"}, log a throttled WARN. |
"dlq" |
Route every raw input payload in the batch to the dlq topic (headers carry turbine_dlq_phase=processing + the exception), then advance. |
Under skip / dlq the batch's durable (RocksDB) state writes are rolled
back before the offset advances, so a half-applied handler can't leave
inconsistent state behind. (In-memory windows keep their state in Python and
are best-effort here, matching their no-durability contract.) The no-loss and
exactly-once couplings are the same as the decode DLQ: the dead-letter write is
flushed before the offset commits, and rides the transaction under
processing_guarantee="exactly_once".
fail is the default because a processing error often signals a real bug you
want to halt on rather than silently skip; switch to skip/dlq for pipelines
where one bad batch shouldn't take the job down. Note that transient errors
are not retried (a retry/backoff policy is separate and not yet available) —
fail halts, skip/dlq drop.
Compute errors¶
The previous section is about bad bytes — a message Turbine can't decode at
all. This section is about bad data: the message decoded fine, but a
computation in your aggregation expressions fails on some rows. The classic
cases are an integer divide-by-zero, a strict cast overflow, and a
parse_ts that can't parse a string. In PyArrow these raise on the whole
batch, so one bad row would otherwise sink the thousand good rows next to it.
The on_compute_error knob on @app.subscribe(...) decides what happens:
on_compute_error |
Behaviour on a failing expression kernel |
|---|---|
"null" (default) |
Null just the offending rows, count them, and process the rest of the batch. |
"fail" |
Re-raise — stop the batch (and the worker), for correctness-critical pipelines. |
from turbine import functions as e
# Default: a zero divisor nulls that row's score; the batch keeps going.
@app.subscribe(kafka.topic("events"), output=kafka.topic("scores"))
class Score:
def __init__(self):
self.win = PersistentTumbling(
size="1m",
value=agg.Mean(e.col("errors") / e.col("requests")), # /0 → null
)
"null" (default) — null the bad rows¶
This is the SQL-faithful default: the same model as streaming SQL engines
(RisingWave, Flink), where a row that fails a computation becomes null and a
null drops out of skip-null aggregates (sum, mean, p95, …). One
divide-by-zero in a window therefore lowers that window's sample count by one
instead of crashing the job. The rest of the batch — and every other row in the
window — is unaffected.
Nulling is never silent. Every nulled row is counted on the always-on
turbine_compute_errors_total{op} metric, where op is the kernel that
failed (divide, cast, parse_ts, …) — a bounded label, never the row
content, so it's safe to alert on. A spike there is your signal that upstream
data quality slipped, even though the pipeline kept running.
Two variants give you the same permissive outcome explicitly and faster (a
vectorised masked kernel, no row-by-row fallback), independent of the policy:
divide_or_null / divide_or_zero and cast_or_null / cast_or_default. Use
those when you expect zeros/overflows as normal data; let the default policy
catch the ones you didn't.
The policy only catches data errors (PyArrow ArrowInvalid). A programming
mistake — referencing a column that doesn't exist — still raises loudly under
either policy; it's a bug, not bad data.
"fail" — stop instead¶
Set on_compute_error="fail" when a computation that can't be evaluated signals
a problem you'd rather halt on than paper over with nulls (financial, audit,
correctness-critical). The kernel re-raises, stopping the batch. This is the
strict, fail-fast counterpart to on_error="fail" on the decode side.