@@ -43,6 +43,7 @@ def on_order_created(data, meta):
4343 HeaderPublisher ,
4444 ReceivedMessage ,
4545 Redeliverer ,
46+ Rewinder ,
4647 Transport ,
4748 make_transport ,
4849 publish_keeping_route ,
@@ -111,6 +112,7 @@ def __init__(
111112 self .shutdown_timeout = float (shutdown_timeout )
112113 self ._handlers : Dict [str , Handler ] = {}
113114 self ._stop = threading .Event ()
115+ self ._rewind_pause = 1.0 # seconds to wait before a rewound gated message is re-polled
114116 self ._phase = _IDLE
115117 self ._last_phase = _IDLE
116118 self ._signalled = False
@@ -295,6 +297,20 @@ def dispatch(self, received: ReceivedMessage) -> None:
295297 self ._phase = _SETTLING
296298 except Exception as exc : # noqa: BLE001 - one bad message must not kill the loop
297299 self ._phase = _SETTLING
300+ if received .route_urn .startswith (_UNSUPPORTED_SCHEMA_ROUTE_PREFIX ):
301+ # A version-gated delivery never takes the generic retry path, even when its
302+ # dead-letter write fails: a retry re-publish may rebuild the version header
303+ # from the (valid) body and walk the message past the gate into a handler.
304+ # It is left unacked; the broker (or a rewind, for cumulative-offset brokers
305+ # such as Kafka) brings it back and the gate rejects it again.
306+ logger .error (
307+ "Dead-lettering a version-gated message on queue %r failed; it was NOT "
308+ "retried or acknowledged; it will come back and the dead-letter write will be retried." ,
309+ received .queue ,
310+ exc_info = exc ,
311+ )
312+ self ._rewind_unsettled (received )
313+ return
298314 self ._retry_or_dead_letter (received , envelope , exc )
299315 else :
300316 # Outside the handler's try: a failed ack is not a handler failure, so it must
@@ -417,6 +433,23 @@ def _ack_settled(self, received: ReceivedMessage) -> None:
417433 exc_info = True ,
418434 )
419435
436+ def _rewind_unsettled (self , received : ReceivedMessage ) -> None :
437+ """Make a deliberately unsettled message come back (cumulative-offset brokers such as Kafka
438+ would otherwise lose it when a later record on the partition is committed), after a short
439+ pause so an unavailable DLQ is not hammered in a hot loop."""
440+ if not isinstance (self .transport , Rewinder ):
441+ return
442+ try :
443+ self .transport .rewind (received )
444+ except Exception : # noqa: BLE001 - reported, never kills the loop
445+ logger .error (
446+ "Failed to rewind an unsettled version-gated message on queue %r." ,
447+ received .queue ,
448+ exc_info = True ,
449+ )
450+ return
451+ self ._stop .wait (self ._rewind_pause )
452+
420453 def _release_unprocessed (self , received : ReceivedMessage ) -> None :
421454 """Release a message whose handler never completed (shutdown): unchanged body, no
422455 backoff, and the runtime adds no ``attempts`` increment. A broker that counts
@@ -440,23 +473,35 @@ def _route_unknown(self, urn: str, received: ReceivedMessage, envelope: Mapping[
440473 if strategy == UnknownUrnStrategy .DELETE :
441474 self ._ack_settled (received )
442475 return
443- # A transport's version-gated delivery, an undecodable body or a body naming no URN can
444- # never gain a handler, so releasing it unchanged would redeliver it forever. It takes the
445- # bounded FAIL path instead (retry up to max_attempts, then dead-letter or drop); only a
446- # genuinely unknown URN keeps the verbatim release.
447- gated = received .route_urn .startswith (_UNSUPPORTED_SCHEMA_ROUTE_PREFIX )
448- poison = gated or not urn
449- if strategy == UnknownUrnStrategy .RELEASE and not poison :
476+ # A transport's version-gated delivery (unsupported ``bq-schema-version``) can never gain
477+ # a handler and must not be retried: a retry re-publishes through paths that may rebuild
478+ # the version header from the (valid) body, or never advance the attempt counter, so the
479+ # gate would be bypassed or the message would loop. It goes straight to the DLQ (or is
480+ # dropped when no DLQ is configured).
481+ if received .route_urn .startswith (_UNSUPPORTED_SCHEMA_ROUTE_PREFIX ):
482+ self ._settle_version_gated (received , envelope )
483+ return
484+ # An undecodable body or a body naming no URN can never gain a handler either, so
485+ # releasing it unchanged would redeliver it forever. It takes the bounded FAIL path
486+ # instead (retry up to max_attempts, then dead-letter or drop); only a genuinely unknown
487+ # URN keeps the verbatim release.
488+ if strategy == UnknownUrnStrategy .RELEASE and urn :
450489 self ._release (received , received .body , self .unknown_urn_release_delay )
451490 return
452491 if strategy == UnknownUrnStrategy .DEAD_LETTER :
453492 self ._dead_letter (received , dict (envelope ), "unknown_urn" , None )
454493 return
455494 # FAIL (and RELEASE of a poison message) — surfaced through the retry/dead-letter path
456- # (never kills the loop). A gated delivery names its reserved route (the rejected version).
457- raise UnknownUrnError (
458- f"No handler mapped for URN [{ urn or (received .route_urn if gated else '' ) or '(empty)' } ]."
459- )
495+ # (never kills the loop).
496+ raise UnknownUrnError (f"No handler mapped for URN [{ urn or '(empty)' } ]." )
497+
498+ def _settle_version_gated (self , received : ReceivedMessage , envelope : Mapping [str , Any ]) -> None :
499+ """Dead-letter a version-gated delivery directly (no retry); drop it when no DLQ is set."""
500+ if self .dead_letter_enabled or self .on_unknown_urn == UnknownUrnStrategy .DEAD_LETTER :
501+ error = UnknownUrnError (f"No handler mapped for URN [{ received .route_urn } ]." )
502+ self ._dead_letter (received , dict (envelope ), "unknown_urn" , error )
503+ return
504+ self ._ack_settled (received )
460505
461506 def _retry_or_dead_letter (
462507 self , received : ReceivedMessage , envelope : Dict [str , Any ], exc : BaseException
@@ -500,7 +545,8 @@ def _dead_letter(
500545#: Reserved ``ReceivedMessage.route_urn`` prefix a transport uses to hand over a delivery it
501546#: version-gated (broker-bindings §5.7 step 3, e.g. the pulsar transport's ``bq-schema-version``
502547#: check). Not a ``urn:babel:`` URN, so no handler owns it. Must equal
503- #: ``pulsar_transport.UNSUPPORTED_SCHEMA_ROUTE_PREFIX`` (locked by a test); never reaches the wire.
548+ #: ``UNSUPPORTED_SCHEMA_ROUTE_PREFIX`` of the pulsar, sqs, asb, kafka and artemis transports (locked
549+ #: by a test); never reaches the wire. A delivery under it is dead-lettered directly, never retried.
504550_UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
505551
506552
0 commit comments