Skip to content

Commit 03fe1b9

Browse files
committed
fix(kafka): confirm retry and DLQ delivery before committing, dead-letter non-UTF-8 records
Retry, retry-to-work and dead-letter writes now go through the new SettlePublisher.publish_settle, which flushes and waits for the broker's delivery report; a failed delivery leaves the offset uncommitted and the record is re-read. A record whose body is not valid UTF-8 is written raw to the DLQ like the Java, Go and Node consumers do. The user-facing publish() keeps its fire-and-forget, strict-encoding behaviour.
1 parent cdb106d commit 03fe1b9

7 files changed

Lines changed: 453 additions & 20 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,30 @@ The envelope wire format is versioned separately by `meta.schema_version`
99

1010
## [Unreleased]
1111

12+
### Added
13+
- `SettlePublisher` (optional transport capability, exported from `babelqueue`) with
14+
`publish_settle(queue, body, route_urn="")`: the runtime's settle writes (retry republish,
15+
envelope / poison / gate-rejection DLQ, `redrive` restore) go through it when a transport
16+
implements it. `KafkaTransport` implements it.
17+
- `KafkaDeliveryError` (a `BabelQueueError`) and the optional `KafkaTransport(delivery_timeout=30.0)`
18+
constructor argument.
19+
20+
### Fixed
21+
- **Kafka: retry / dead-letter writes are now broker-confirmed before the offset is committed.**
22+
`KafkaTransport.publish_settle()` waits for this write's own delivery report
23+
(`flush(delivery_timeout)`). A delivery error or timeout raises `KafkaDeliveryError`, nothing is
24+
committed, and the record is re-read through the existing rewind/seek path — so such a write can
25+
no longer be lost to a commit that raced ahead of it. `publish()` (and so `app.publish()`) is
26+
unchanged: still fire-and-forget and strict (a body that is not valid UTF-8 still raises
27+
`UnicodeEncodeError`), also from inside a handler.
28+
- **Kafka: a record whose value is not valid UTF-8 is dead-lettered raw instead of raising.** The
29+
original bytes are written to `<queue>.dlq` unchanged, with no projected headers (as
30+
Java/Go/Node do for poison records), and the offset is committed only once that write is
31+
confirmed; with the DLQ off, or `UnknownUrnStrategy.DELETE`, the record is dropped and
32+
committed. It reuses the version-gate settle path, so a failed write logs as a "version-gated"
33+
message — the logged route (`...:non-utf8-payload`) tells them apart. A non-UTF-8 `bq-attempts`
34+
header no longer raises. No wire change.
35+
1236
## [1.16.1] - 2026-10-11
1337

1438
### Changed

‎src/babelqueue/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
Redeliverer,
3737
Rewinder,
3838
RoutedPublisher,
39+
SettlePublisher,
3940
Transport,
4041
)
4142

@@ -56,6 +57,7 @@
5657
"Redeliverer",
5758
"Rewinder",
5859
"RoutedPublisher",
60+
"SettlePublisher",
5961
"BabelQueueError",
6062
"UnknownUrnError",
6163
"DecryptError",

‎src/babelqueue/app.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,8 +308,9 @@ def dispatch(self, received: ReceivedMessage) -> None:
308308
# It is left unacked; the broker (or a rewind, for cumulative-offset brokers
309309
# such as Kafka) brings it back and the gate rejects it again.
310310
logger.error(
311-
"Dead-lettering a version-gated message on queue %r failed; it was NOT "
311+
"Dead-lettering a version-gated message (route %r) on queue %r failed; it was NOT "
312312
"retried or acknowledged; it will come back and the dead-letter write will be retried.",
313+
received.route_urn,
313314
received.queue,
314315
exc_info=exc,
315316
)
@@ -447,7 +448,8 @@ def _rewind_unsettled(self, received: ReceivedMessage) -> None:
447448
self.transport.rewind(received)
448449
except Exception: # noqa: BLE001 - reported, never kills the loop
449450
logger.error(
450-
"Failed to rewind an unsettled version-gated message on queue %r.",
451+
"Failed to rewind an unsettled version-gated message (route %r) on queue %r.",
452+
received.route_urn,
451453
received.queue,
452454
exc_info=True,
453455
)

‎src/babelqueue/kafka_transport.py‎

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515
body's ``attempts`` is the fallback for non-BabelQueue producers); the runtime owns retry by
1616
republishing with attempts+1 and dead-letters to ``<queue>.dlq``.
1717
18+
Settlement writes (retry republish, DLQ) go through ``publish_settle`` and wait for the broker's
19+
delivery report (``delivery_timeout``); a failure raises ``KafkaDeliveryError`` and the offset is
20+
not committed. ``publish`` itself stays fire-and-forget. A record value that is not valid UTF-8 is handed to the runtime under a
21+
reserved route so its original bytes are dead-lettered raw (written back with no projected headers;
22+
with the DLQ off, or ``UnknownUrnStrategy.DELETE``, it is dropped and committed). The runtime logs
23+
it as a version-gated message because it reuses that settle path; the route in the log names it.
24+
1825
This implements §6 of the broker-bindings contract. The envelope is unchanged
1926
(``schema_version`` stays 1); Apache Kafka is purely additive.
2027
@@ -31,6 +38,7 @@
3138
from urllib.parse import quote
3239

3340
from .codec import SCHEMA_VERSION, EnvelopeCodec, parse_envelope
41+
from .exceptions import BabelQueueError
3442
from .transport import ReceivedMessage, Transport, truncate_declared_version
3543

3644
_logger = logging.getLogger("babelqueue.kafka")
@@ -45,9 +53,23 @@
4553
#: rejected ``bq-schema-version`` is named in the logged error.
4654
UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
4755

56+
#: Reserved ``route_urn`` of a record whose value is not valid UTF-8. Like a version-gated record it
57+
#: can never gain a handler and must not be retried (a retry would re-publish the same bytes), so it
58+
#: reuses the gated route: the runtime dead-letters it directly, byte-identical, and drops it when no
59+
#: DLQ is configured. The body carries the raw bytes as ``surrogateescape`` text, which
60+
#: :meth:`KafkaTransport.publish` encodes back to the exact original bytes.
61+
NON_UTF8_ROUTE = UNSUPPORTED_SCHEMA_ROUTE_PREFIX + "non-utf8-payload"
62+
63+
#: How long (seconds) a settle-path publish waits for the broker to confirm the write.
64+
DEFAULT_DELIVERY_TIMEOUT = 30.0
65+
4866
Headers = List[Tuple[str, bytes]]
4967

5068

69+
class KafkaDeliveryError(BabelQueueError):
70+
"""A retry / dead-letter write was not confirmed by the broker (delivery error or timeout)."""
71+
72+
5173
def _brokers_from_url(url: str) -> str:
5274
rest = url.split("://", 1)[1] if "://" in url else url
5375
return rest.split("/", 1)[0] or "localhost:9092"
@@ -61,10 +83,12 @@ def __init__(
6183
producer: Any = None,
6284
consumer_factory: Optional[Callable[[str], Any]] = None,
6385
group_id: str = "babelqueue",
86+
delivery_timeout: float = DEFAULT_DELIVERY_TIMEOUT,
6487
**client_config: Any,
6588
) -> None:
6689
self._brokers = _brokers_from_url(url or "kafka://localhost:9092")
6790
self._group_id = group_id
91+
self._delivery_timeout = delivery_timeout
6892
self._client_config = client_config
6993
self._producer = producer
7094
self._consumer_factory = consumer_factory
@@ -143,7 +167,13 @@ def _reconcile(body: str, headers: Any) -> str:
143167
attempts = int(env.get("attempts", 0) or 0)
144168
for key, value in headers or []:
145169
if key == "bq-attempts":
146-
raw = value.decode("utf-8") if isinstance(value, (bytes, bytearray)) else str(value)
170+
# ``replace``: a non-UTF-8 header value must not raise out of ``pop``; it simply
171+
# fails ``int()`` below and the body's own attempts stand.
172+
raw = (
173+
bytes(value).decode("utf-8", "replace")
174+
if isinstance(value, (bytes, bytearray))
175+
else str(value)
176+
)
147177
try:
148178
attempts = int(raw)
149179
except (ValueError, TypeError):
@@ -182,24 +212,78 @@ def _unsupported_schema_version(headers: Any) -> Optional[str]:
182212

183213
@staticmethod
184214
def _payload(message: Any) -> str:
215+
"""The record value as text. Never raises: bytes that are not UTF-8 are kept as
216+
``surrogateescape`` code points, so :meth:`publish` can write the exact bytes back."""
185217
value = message.value()
186218
if isinstance(value, (bytes, bytearray)):
187-
return value.decode("utf-8")
219+
return bytes(value).decode("utf-8", "surrogateescape")
188220
return str(value) if value is not None else ""
189221

222+
@staticmethod
223+
def _is_non_utf8(message: Any) -> bool:
224+
value = message.value()
225+
if not isinstance(value, (bytes, bytearray)):
226+
return False
227+
try:
228+
bytes(value).decode("utf-8")
229+
except UnicodeDecodeError:
230+
return True
231+
return False
232+
190233
# -- Transport ----------------------------------------------------------
191234

192235
def publish(self, queue: str, body: str) -> None:
193-
env = parse_envelope(body)
194-
meta = env.get("meta") or {}
195236
producer = self._producer_()
196-
kwargs: Dict[str, Any] = {"value": body.encode("utf-8"), "headers": self._projection(body)}
197-
created_at = meta.get("created_at")
198-
if created_at:
199-
kwargs["timestamp"] = int(created_at)
237+
kwargs = self._record_kwargs(body)
200238
producer.produce(queue, **kwargs)
201239
producer.poll(0)
202240

241+
def publish_settle(self, queue: str, body: str, route_urn: str = "") -> None:
242+
""":class:`~babelqueue.transport.SettlePublisher`: the runtime's settle write (retry
243+
republish, envelope / poison / gate DLQ, ``redrive`` restore). Unlike :meth:`publish` it
244+
waits for the broker's delivery report, because the offset may only be committed once the
245+
write is confirmed; a delivery error or timeout raises :class:`KafkaDeliveryError`."""
246+
self._produce_confirmed(self._producer_(), queue, self._record_kwargs(body, raw_ok=True))
247+
248+
@classmethod
249+
def _record_kwargs(cls, body: str, raw_ok: bool = False) -> Dict[str, Any]:
250+
"""``produce`` arguments for ``body``. With ``raw_ok`` (the settle path only), a body
251+
carrying ``surrogateescape`` code points (a consumed record whose value was not UTF-8) is
252+
written back byte-for-byte with no projected headers: it is not a trustworthy envelope,
253+
and the projection could not encode it. Without it (a user ``publish``) such a body is
254+
rejected with ``UnicodeEncodeError``, as before."""
255+
try:
256+
body.encode("utf-8")
257+
except UnicodeEncodeError:
258+
if not raw_ok:
259+
raise
260+
return {"value": body.encode("utf-8", "surrogateescape"), "headers": []}
261+
kwargs: Dict[str, Any] = {"value": body.encode("utf-8"), "headers": cls._projection(body)}
262+
created_at = (parse_envelope(body).get("meta") or {}).get("created_at")
263+
if created_at:
264+
kwargs["timestamp"] = int(created_at)
265+
return kwargs
266+
267+
def _produce_confirmed(self, producer: Any, queue: str, kwargs: Dict[str, Any]) -> None:
268+
"""Produce and wait for the delivery report; raise :class:`KafkaDeliveryError` when the
269+
broker reported an error or did not confirm within ``delivery_timeout``."""
270+
outcome: Dict[str, Any] = {}
271+
272+
def on_delivery(err: Any, _msg: Any = None) -> None:
273+
outcome["error"] = err
274+
275+
producer.produce(queue, on_delivery=on_delivery, **kwargs)
276+
producer.flush(self._delivery_timeout)
277+
# ``flush`` returns the count of *every* message still queued on the producer, so it says
278+
# nothing about this write: only this write's own delivery report decides. No report within
279+
# the timeout means it is unconfirmed.
280+
if "error" not in outcome:
281+
raise KafkaDeliveryError(
282+
f"Kafka delivery to {queue!r} was not confirmed within {self._delivery_timeout}s."
283+
)
284+
if outcome["error"] is not None:
285+
raise KafkaDeliveryError(f"Kafka delivery to {queue!r} failed: {outcome['error']}")
286+
203287
def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
204288
wait = timeout if timeout and timeout > 0 else 1.0
205289
self._rewind_abandoned(queue)
@@ -229,6 +313,18 @@ def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
229313
)
230314
self._pending[queue] = message
231315
return received
316+
if self._is_non_utf8(message):
317+
# The value cannot be decoded, so there is no envelope to retry or annotate: hand it
318+
# over under the reserved route; the runtime dead-letters the raw bytes directly (and
319+
# does not commit if that write is not confirmed) instead of letting ``pop`` raise.
320+
_logger.warning(
321+
"Kafka record on %r is not valid UTF-8; dead-lettering its raw bytes.", queue
322+
)
323+
received = ReceivedMessage(
324+
body=self._payload(message), queue=queue, handle=message, route_urn=NON_UTF8_ROUTE
325+
)
326+
self._pending[queue] = message
327+
return received
232328
body = self._reconcile(self._payload(message), headers)
233329
received = ReceivedMessage(body=body, queue=queue, handle=message)
234330
# Track the record as in flight only once ``pop`` has built it. A decode error above

‎src/babelqueue/transport.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,29 @@ def publish_routed(self, queue: str, body: str, route_urn: str) -> None:
9090
...
9191

9292

93+
@runtime_checkable
94+
class SettlePublisher(Protocol):
95+
"""Optional :class:`Transport` capability: a re-publish that *settles* a received message
96+
(retry, release, dead-letter, poison/gate DLQ, ``redrive`` restore) and so must be
97+
broker-confirmed before the delivery is acknowledged. Brokers whose acknowledgement is a
98+
cumulative position (Kafka) need it: committing before the write is confirmed loses the
99+
record on a delivery failure. It raises when the write is not confirmed; the caller then leaves
100+
the delivery unsettled. A user's own :meth:`Transport.publish` is never routed here. A transport
101+
that does not implement it gets the :class:`RoutedPublisher` / plain-publish behaviour."""
102+
103+
def publish_settle(self, queue: str, body: str, route_urn: str = "") -> None:
104+
"""Append ``body`` to ``queue`` and return only once the broker confirmed the write."""
105+
...
106+
107+
93108
def publish_keeping_route(transport: "Transport", queue: str, body: str, route_urn: str = "") -> None:
94109
"""Publish ``body`` to ``queue``, carrying ``route_urn`` when it is non-empty and the
95110
transport implements :class:`RoutedPublisher`; otherwise a plain :meth:`Transport.publish`.
111+
A :class:`SettlePublisher` transport gets the confirmed settle write instead.
96112
Shared by the runtime's re-publishes (retry, release, dead-letter) and by ``redrive``."""
97-
if route_urn and isinstance(transport, RoutedPublisher):
113+
if isinstance(transport, SettlePublisher):
114+
transport.publish_settle(queue, body, route_urn)
115+
elif route_urn and isinstance(transport, RoutedPublisher):
98116
transport.publish_routed(queue, body, route_urn)
99117
else:
100118
transport.publish(queue, body)

‎tests/fakes/confluent_kafka/__init__.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,23 @@ def offset(self): return self._o
2727

2828

2929
class Producer:
30-
def __init__(self, conf): pass
30+
def __init__(self, conf): self._reports = []
3131

32-
def produce(self, topic, value=None, headers=None, timestamp=None, **kw):
32+
def produce(self, topic, value=None, headers=None, timestamp=None, on_delivery=None, **kw):
3333
with _LOCK:
3434
log = TOPICS.setdefault(topic, []) # auto-create, like the real broker default
3535
log.append(Message(topic, len(log), value, list(headers or [])))
36+
if on_delivery is not None:
37+
self._reports.append(on_delivery)
3638

3739
def poll(self, t): return 0
38-
def flush(self, *a): return 0
40+
41+
def flush(self, *a):
42+
# Serve the delivery reports like librdkafka does: from poll()/flush(), all successful.
43+
reports, self._reports = self._reports, []
44+
for report in reports:
45+
report(None, None)
46+
return 0
3947

4048

4149
class Consumer:

0 commit comments

Comments
 (0)