Failure Handling¶
When to read this: something in your pipeline is failing and you need to decide whether to crash, skip, or queue for later.
For the full list of runtime promises this page builds on (sink fail-closed semantics, checkpoint advancement under failure, DLQ replay acknowledgement), see Runtime Guarantees.
What happens when a middleware raises¶
When a middleware's process() raises an exception, the chain stops for that record. No subsequent middlewares run, and the record does not reach the sink. The runtime calls the middleware's on_error() hook (which logs by default), then checks whether a DLQ is configured.
If a DLQ sink is configured, the runtime attempts to write the failed record
there as a DLQRecord with stage="middleware" and the middleware name in
DLQRecord.middleware.
- if that DLQ write succeeds, the pipeline continues with the next record
- if that DLQ write fails and no checkpoint is in play for the record, the
failure is logged under
DLQFailurePolicy.LOG_ONLYand the pipeline can still continue - if that DLQ write fails and checkpointing is enabled for the record, the run stops before later records can advance the checkpoint past an unhandled failure
If no DLQ is configured, the error is counted in PipelineRunSummary.records_errored and the record is silently discarded.
The pipeline itself does not stop on a middleware error. If you want it to stop, raise inside on_error() or use SinkFailurePolicy.FAIL_CLOSED at the build step (covered below).
What happens when a sink raises¶
Sink failures are handled separately from middleware failures. When write() raises, the runtime consults SinkFailurePolicy.
SinkFailurePolicy.FAIL_CLOSED (default)¶
The pipeline raises immediately and stops. Use this when a write failure means the data is lost and you cannot afford to continue — for example, writing to a primary database where every record must land.
from agora import DeliveryConfig, SinkFailurePolicy
bound = (
Pipeline(source)
.pipe(NormalizeMiddleware())
.build(
PostgresSink(dsn=dsn),
config=DeliveryConfig(sink_failure_policy=SinkFailurePolicy.FAIL_CLOSED),
)
)
FAIL_CLOSED is the default, so you only need to set it explicitly when overriding a previous configuration.
SinkFailurePolicy.LOG_AND_CONTINUE¶
The error is logged, the record is counted as errored, and the pipeline moves on. Use this when partial delivery is acceptable — for example, writing to a secondary analytics store where a missed record is tolerable.
from agora import DeliveryConfig
from agora.core.dlq import SQLiteDLQSink
from agora import SinkFailurePolicy
bound = (
Pipeline(source)
.pipe(NormalizeMiddleware())
.build(
AnalyticsSink(),
config=DeliveryConfig(
sink_failure_policy=SinkFailurePolicy.LOG_AND_CONTINUE,
dlq=SQLiteDLQSink(".agora_dlq.db"),
),
)
)
Pair LOG_AND_CONTINUE with a DLQ so failed records are not silently lost. Without a DLQ, LOG_AND_CONTINUE means the record is gone.
The DLQ¶
A DLQ (dead-letter queue) is just another sink — one that receives DLQRecord objects instead of your domain records. You attach it at .build() time:
from agora import DeliveryConfig
from agora.core.dlq import SQLiteDLQSink
dlq = SQLiteDLQSink(".agora_dlq.db")
bound = (
Pipeline(source)
.pipe(NormalizeMiddleware())
.build(
PostgresSink(dsn=dsn),
config=DeliveryConfig(dlq=dlq),
)
)
Every failed record — whether from a middleware or a sink — is written to the DLQ with enough context to replay it: the pipeline ID, run ID, stage name, error type, error message, and the original record payload.
DLQFailurePolicy¶
If the DLQ sink itself fails to write, DLQFailurePolicy controls what happens next.
DLQFailurePolicy.LOG_ONLY (default) — log the DLQ write failure and continue
when doing so does not violate checkpoint correctness. The original error is
already counted; losing the DLQ entry is treated as a secondary failure only
when the runtime can still keep resume semantics honest.
DLQFailurePolicy.RAISE — treat a DLQ write failure as fatal and stop the pipeline. Use this when you need a hard guarantee that no failed record is silently discarded.
from agora import DeliveryConfig, DLQFailurePolicy
bound = (
Pipeline(source)
.pipe(NormalizeMiddleware())
.build(
PostgresSink(dsn=dsn),
config=DeliveryConfig(
dlq=SQLiteDLQSink(".agora_dlq.db"),
dlq_failure_policy=DLQFailurePolicy.RAISE,
),
)
)
When to use DLQ vs retry vs passthrough¶
Use a DLQ when the failure is likely transient or data-dependent and you want to inspect and replay later. A DLQ is the right default for production pipelines where you cannot afford to lose records but also cannot block indefinitely on a single bad one.
Use RetryMiddleware when the failure is transient and you want to recover in-process without human intervention — for example, a flaky HTTP enrichment call:
from agora import DeliveryConfig
from agora import RetryMiddleware
pipeline = (
Pipeline(source)
.pipe(RetryMiddleware(EnrichMiddleware(), max_retries=3, backoff_base=2.0))
.build(sink, config=DeliveryConfig(dlq=SQLiteDLQSink(".agora_dlq.db")))
)
RetryMiddleware wraps any middleware and retries on exception with exponential backoff. If all retries are exhausted, the exception propagates and the record lands in the DLQ (if configured). Combine retry with a DLQ: retry handles transient failures, the DLQ catches the ones that don't recover.
Use OnError.PASSTHROUGH (on individual middlewares that support it) when a failure in that stage should not block the record from reaching the sink. This is appropriate for optional enrichment where a missing value is acceptable — the record passes through with whatever the middleware managed to produce before failing.
Do not use LOG_AND_CONTINUE without a DLQ as a substitute for passthrough. LOG_AND_CONTINUE discards the record at the sink level; passthrough keeps it moving through the pipeline.
SQLiteDLQSink¶
SQLiteDLQSink writes failed records to a local SQLite file. It's the right choice for development, single-process deployments, and any situation where you don't have a message broker available.
from agora import DeliveryConfig
from agora.core.dlq import SQLiteDLQSink
from agora import SinkFailurePolicy
dlq = SQLiteDLQSink(".agora_dlq.db") # path is relative to cwd
bound = (
Pipeline(source)
.pipe(NormalizeMiddleware())
.build(
PostgresSink(dsn=dsn),
config=DeliveryConfig(
dlq=dlq,
sink_failure_policy=SinkFailurePolicy.LOG_AND_CONTINUE,
),
)
)
summary = await bound.run()
print(f"Failed records in DLQ: {summary.records_errored}")
The database is created automatically on first use. The schema is a single dlq_records table with columns for pipeline ID, run ID, stage, error type, error message, the serialized record, and retry attempt counters.
SQLiteDLQSink is not safe for concurrent writes from multiple processes. For multi-process deployments, use a broker-backed DLQ sink instead.
Replaying the DLQ¶
Once you've fixed the underlying issue, replay failed records using SQLiteDLQSource as the pipeline source:
import asyncio
from agora import MapMiddleware, Pipeline
from agora.core.dlq import SQLiteDLQSink, SQLiteDLQSource
async def replay() -> None:
dlq_sink = SQLiteDLQSink(".agora_dlq.db")
source = SQLiteDLQSource(
".agora_dlq.db",
pipeline_id="my_pipeline", # replay only this pipeline's failures
stage="sink_write", # optional: filter by failure stage
)
# The DLQ source yields DLQRecord objects.
# Use replay_payload() to extract the original record for re-processing.
async def unwrap_and_ack(dlq_record):
payload = dlq_record.replay_payload("pipeline")
# acknowledge removes the record from the DLQ after successful replay
await dlq_sink.acknowledge(dlq_record)
return payload
summary = await (
Pipeline(source)
.pipe(MapMiddleware(unwrap_and_ack, name="unwrap"))
.pipe(NormalizeMiddleware())
.build(PostgresSink(dsn=dsn))
.run()
)
print(summary)
asyncio.run(replay())
replay_payload("pipeline") returns the original pre-middleware record so the full pipeline runs again. Use replay_payload("sink") if the failure was at the sink stage and the record was already transformed — you want to skip re-processing and write directly.
SQLiteDLQSource only yields records where attempt < max_attempts (or all records when max_attempts is None). Records that have hit their retry ceiling are skipped automatically.
agora dlq replay (CLI)¶
If you're running pipelines via the agora runner, the CLI provides a shortcut:
agora dlq replay --config pipelines.toml my_pipeline
agora dlq replay --config pipelines.toml my_pipeline --stage sink_write
agora dlq replay --config pipelines.toml my_pipeline --mode sink
This reads the DLQ backend from your pipeline config, builds the matching DLQ
source automatically, and replays only records for the selected pipeline. See
agora dlq --help for filtering options.
Putting it together¶
A production pipeline that handles failures gracefully looks like this:
import asyncio
from agora import Backpressure, DLQFailurePolicy, DeliveryConfig, RetryMiddleware, SinkFailurePolicy
from agora.core.dlq import SQLiteDLQSink
from agora import Pipeline
async def main() -> None:
dlq = SQLiteDLQSink(".agora_dlq.db")
summary = await (
Pipeline(KafkaSource(topics=["raw_events"], config=kafka_cfg), id="event_pipeline")
.pipe(NormalizeMiddleware())
.pipe(RetryMiddleware(EnrichMiddleware(), max_retries=3, backoff_base=2.0))
.filter(lambda r: r.confidence > 0.5)
.build(
PostgresSink(dsn=dsn),
config=DeliveryConfig(
dlq=dlq,
dlq_failure_policy=DLQFailurePolicy.LOG_ONLY,
sink_failure_policy=SinkFailurePolicy.LOG_AND_CONTINUE,
batch_size=100,
backpressure=Backpressure.adaptive(max_buffer_size=500),
),
)
.run()
)
print(summary)
if summary.records_errored > 0:
print(f" {summary.records_errored} records in DLQ — run `agora dlq replay` to retry")
asyncio.run(main())
The retry middleware handles transient enrichment failures in-process. Anything that exhausts retries or fails at the sink lands in the DLQ. The pipeline keeps running. After the run, you inspect the summary and replay if needed.