1515body's ``attempts`` is the fallback for non-BabelQueue producers); the runtime owns retry by
1616republishing 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+
1825This implements §6 of the broker-bindings contract. The envelope is unchanged
1926(``schema_version`` stays 1); Apache Kafka is purely additive.
2027
3138from urllib .parse import quote
3239
3340from .codec import SCHEMA_VERSION , EnvelopeCodec , parse_envelope
41+ from .exceptions import BabelQueueError
3442from .transport import ReceivedMessage , Transport , truncate_declared_version
3543
3644_logger = logging .getLogger ("babelqueue.kafka" )
4553#: rejected ``bq-schema-version`` is named in the logged error.
4654UNSUPPORTED_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+
4866Headers = 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+
5173def _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
0 commit comments