Measured on 0.2.0 with aiokafka 0.14.0 and a Kafka container paused mid-run, so the broker neither answers nor refuses. Twenty pending outbox rows, OmniBoxDomainService(max_attempts=6) (the default), KafkaEventPublisher(max_infra_retries=1), a relay loop calling publish_batch once per cycle:
--- Kafka paused; the relay keeps ticking every cycle ---
cycle 1 at 40.1 s: {'pending/attempts=1': 20}
cycle 2 at 80.2 s: {'pending/attempts=2': 20}
cycle 3 at 120.3 s: {'pending/attempts=3': 20}
cycle 4 at 160.5 s: {'pending/attempts=4': 20}
cycle 5 at 200.6 s: {'pending/attempts=5': 20}
cycle 6 at 240.7 s: {'failed/attempts=6': 20}
cycle 7 at 240.7 s: {'failed/attempts=6': 20}
--- Kafka is back ---
next relay cycle: {'failed/attempts=6': 20}; messages on the topic: 0
after requeue_failed on every failed row and one more cycle: {'completed/attempts=0': 20}; messages on the topic: 20
Every cycle the broker is unreachable costs every pending row one attempt, and after max_attempts cycles the whole backlog is failed, which is terminal. When Kafka returns the relay publishes nothing, because fetch_and_lock_pending filters on attempts_made < max_attempts. The events are recovered only by someone running requeue_failed on each row. A relay ticking every second turns a four-minute broker outage into a backlog that never drains on its own, and the outbox's one promise, that nothing is lost, is kept only in the sense that the rows are still in the table.
The attempt budget is the right tool for a row that cannot be published: a payload the broker rejects, a topic that does not exist, a serialization error. It is the wrong tool for a broker that is not there, which is a property of the cycle, not of any row. Rule 13 says nothing classifies errors for the pipeline and that ErrorClassifier is used only inside the Kafka publisher's own infrastructure retry; once max_infra_retries is spent the exception reaches the pipeline as a counted failure like any other.
What I think it needs: infrastructure failures from the broker (connection, metadata, request timeout, the classes ErrorClassifier already knows) should reach the outbox pipeline as a non-counted failure with a next_retry_at backoff, the way handler_retry(count_as_attempt=False, next_retry_at=…) already lets a handler say "not my fault, try later". The publisher step is the natural place: it has the exception, it has the classifier, and the pipeline already supports the outcome. A counted failure stays reserved for errors that are about the event. With that, the table above ends with twenty pending rows that publish on the first cycle after Kafka returns, and requeue_failed goes back to being an operator's tool for genuinely poisoned rows. The docs (rule 13, the outbox guide, the runbook if there is one) follow.
Script: kafka_down_lab.py in https://github.com/bedrock-python/bedrock-python.github.io/tree/docs/production-python-series/docs/blog/lab/2026-09-07-when-kafka-is-down. It takes about five minutes because the paused cycles wait on aiokafka's request timeout per event.
Measured on 0.2.0 with aiokafka 0.14.0 and a Kafka container paused mid-run, so the broker neither answers nor refuses. Twenty pending outbox rows,
OmniBoxDomainService(max_attempts=6)(the default),KafkaEventPublisher(max_infra_retries=1), a relay loop callingpublish_batchonce per cycle:Every cycle the broker is unreachable costs every pending row one attempt, and after
max_attemptscycles the whole backlog isfailed, which is terminal. When Kafka returns the relay publishes nothing, becausefetch_and_lock_pendingfilters onattempts_made < max_attempts. The events are recovered only by someone runningrequeue_failedon each row. A relay ticking every second turns a four-minute broker outage into a backlog that never drains on its own, and the outbox's one promise, that nothing is lost, is kept only in the sense that the rows are still in the table.The attempt budget is the right tool for a row that cannot be published: a payload the broker rejects, a topic that does not exist, a serialization error. It is the wrong tool for a broker that is not there, which is a property of the cycle, not of any row. Rule 13 says nothing classifies errors for the pipeline and that
ErrorClassifieris used only inside the Kafka publisher's own infrastructure retry; oncemax_infra_retriesis spent the exception reaches the pipeline as a counted failure like any other.What I think it needs: infrastructure failures from the broker (connection, metadata, request timeout, the classes
ErrorClassifieralready knows) should reach the outbox pipeline as a non-counted failure with anext_retry_atbackoff, the wayhandler_retry(count_as_attempt=False, next_retry_at=…)already lets a handler say "not my fault, try later". The publisher step is the natural place: it has the exception, it has the classifier, and the pipeline already supports the outcome. A counted failure stays reserved for errors that are about the event. With that, the table above ends with twentypendingrows that publish on the first cycle after Kafka returns, andrequeue_failedgoes back to being an operator's tool for genuinely poisoned rows. The docs (rule 13, the outbox guide, the runbook if there is one) follow.Script:
kafka_down_lab.pyin https://github.com/bedrock-python/bedrock-python.github.io/tree/docs/production-python-series/docs/blog/lab/2026-09-07-when-kafka-is-down. It takes about five minutes because the paused cycles wait on aiokafka's request timeout per event.