Skip to content

Commit cc14875

Browse files
committed
fix(gate): forward the raw body to the DLQ on a schema-version rejection
A delivery rejected by the bq-schema-version gate is now dead-lettered as its raw body, byte-identical to the incoming one, with no dead_letter block and without ever being decoded. This matches the Java, .NET and Node consumers and the reject-without-decoding rule of contract sections 3.7/4.7/5.7/6.7/7.7. A failed DLQ write still leaves the message unacknowledged for redelivery.
1 parent 9f4f431 commit cc14875

15 files changed

Lines changed: 106 additions & 72 deletions

‎CHANGELOG.md‎

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

1010
## [Unreleased]
1111

12+
### Changed
13+
- **Schema-version gate rejection now dead-letters the incoming body, byte-identical.** A message
14+
whose `bq-schema-version` (`bq_schema_version` on Artemis) is unsupported is rejected *without
15+
decoding it* (broker-bindings §3.7/§4.7/§5.7/§6.7/§7.7) and, when the DLQ is enabled, the DLQ
16+
record is the raw body as received, with no `dead_letter` block. Previously the core decoded
17+
the envelope and wrote an annotated `dead_letter{reason:"unknown_urn"}` record. This aligns the
18+
Python SDK with Java, .NET and Node. A DLQ consumer that expected the annotation for gated
19+
messages is affected; a failed DLQ write still leaves the message unsettled (rewound on Kafka),
20+
and DLQ off / `UnknownUrnStrategy.DELETE` still drops it. No wire or API change.
21+
1222
### Fixed
1323
- **Kafka: a record whose settlement raised was silently lost on a consume-loop restart.** When a
1424
retry / release / dead-letter `publish` failed (or the loop was otherwise interrupted before

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,8 @@ ASCII whitespace) follows the normal path; any other value must be exactly `"1"`
132132
untrimmed; Azure Service Bus and Artemis also accept the integer `1`). A message carrying
133133
anything else is not decoded: it reaches the runtime on the reserved route
134134
`babelqueue:unsupported-schema-version:<v>` (the version percent-escaped) and is **never
135-
retried** — it goes straight to the dead-letter queue (reason `unknown_urn`, the body kept
136-
verbatim) when `dead_letter` is enabled, and is dropped otherwise, whatever `max_attempts` is.
135+
retried** — it goes straight to the dead-letter queue (the incoming body, byte-identical,
136+
with no `dead_letter` block) when `dead_letter` is enabled, and is dropped otherwise, whatever `max_attempts` is.
137137
It does not depend on a broker `RedrivePolicy`. If the dead-letter write fails the message is
138138
left unacknowledged (an error is logged), is never retried and never reaches a handler; the
139139
dead-letter write is attempted again when the broker brings it back, which depends on the

‎src/babelqueue/app.py‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,11 @@ def dispatch(self, received: ReceivedMessage) -> None:
279279
with _headers_scope(received.headers), _replay_scope(
280280
bool(received.headers.get(HEADER_REPLAY_BYPASS))
281281
):
282-
envelope = EnvelopeCodec.decode(received.body)
282+
# A version-gated delivery is rejected without decoding its body (broker-bindings
283+
# §3.7/§4.7/§5.7/§6.7/§7.7): the raw body goes to the DLQ byte-identical, so there is
284+
# no envelope to read here.
285+
gated = received.route_urn.startswith(_UNSUPPORTED_SCHEMA_ROUTE_PREFIX)
286+
envelope = {} if gated else EnvelopeCodec.decode(received.body)
283287
urn = str(envelope.get("job") or envelope.get("urn") or "")
284288
if received.route_urn and envelope:
285289
# Transport-supplied routing override; body untouched. Only for a body that
@@ -479,7 +483,7 @@ def _route_unknown(self, urn: str, received: ReceivedMessage, envelope: Mapping[
479483
# gate would be bypassed or the message would loop. It goes straight to the DLQ (or is
480484
# dropped when no DLQ is configured).
481485
if received.route_urn.startswith(_UNSUPPORTED_SCHEMA_ROUTE_PREFIX):
482-
self._settle_version_gated(received, envelope)
486+
self._settle_version_gated(received)
483487
return
484488
# An undecodable body or a body naming no URN can never gain a handler either, so
485489
# releasing it unchanged would redeliver it forever. It takes the bounded FAIL path
@@ -495,11 +499,21 @@ def _route_unknown(self, urn: str, received: ReceivedMessage, envelope: Mapping[
495499
# (never kills the loop).
496500
raise UnknownUrnError(f"No handler mapped for URN [{urn or '(empty)'}].")
497501

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."""
502+
def _dead_letter_target(self, received: ReceivedMessage) -> str:
503+
"""The queue a dead-lettered delivery is written to (configured DLQ or ``<queue><suffix>``)."""
504+
return self.dead_letter_queue or (received.queue + self.dead_letter_suffix)
505+
506+
def _settle_version_gated(self, received: ReceivedMessage) -> None:
507+
"""Dead-letter a version-gated delivery directly (no retry); drop it when no DLQ is set.
508+
509+
The gate rejects the message *without decoding it*, so the DLQ record is the incoming raw
510+
body, byte-identical, with no ``dead_letter`` block (matching the Java, .NET and Node
511+
SDKs). A failed DLQ write propagates: the delivery is left unacknowledged and handed back
512+
(see :meth:`dispatch`), never retried.
513+
"""
500514
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)
515+
self._publish_for(received, self._dead_letter_target(received), received.body)
516+
self._ack_settled(received)
503517
return
504518
self._ack_settled(received)
505519

@@ -537,8 +551,9 @@ def _dead_letter(
537551
error=(str(exc) if exc is not None else None),
538552
exception=(type(exc).__name__ if exc is not None else None),
539553
)
540-
target = self.dead_letter_queue or (received.queue + self.dead_letter_suffix)
541-
self._publish_for(received, target, EnvelopeCodec.encode(annotated))
554+
self._publish_for(
555+
received, self._dead_letter_target(received), EnvelopeCodec.encode(annotated)
556+
)
542557
self._ack_settled(received)
543558

544559

‎src/babelqueue/artemis_transport.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,9 @@
4747
#: U+0085, U+00A0, U+2028, U+3000 ...
4848
_BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
4949
#: Prefix of the reserved ``route_urn`` a version-gated message is delivered under. It is not a
50-
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime reports it as an
51-
#: unknown URN, which names the rejected ``bq_schema_version``.
50+
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime dead-letters it
51+
#: directly (never retried, never decoded; the DLQ record is the incoming raw body). The
52+
#: rejected ``bq_schema_version`` is named in the logged error.
5253
UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
5354

5455
JMS_TYPE_ANNOTATION = "x-opt-jms-type"

‎src/babelqueue/asb_transport.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@
3838
#: U+0085, U+00A0, U+2028, U+3000 ...
3939
_BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
4040
#: Prefix of the reserved ``route_urn`` a version-gated message is delivered under. It is not a
41-
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime reports it as an
42-
#: unknown URN, which names the rejected ``bq-schema-version``.
41+
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime dead-letters it
42+
#: directly (never retried, never decoded; the DLQ record is the incoming raw body). The
43+
#: rejected ``bq-schema-version`` is named in the logged error.
4344
UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
4445

4546

‎src/babelqueue/kafka_transport.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@
4040
#: U+0085, U+00A0, U+2028, U+3000 ...
4141
_BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
4242
#: Prefix of the reserved ``route_urn`` a version-gated message is delivered under. It is not a
43-
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime reports it as an
44-
#: unknown URN, which names the rejected ``bq-schema-version``.
43+
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime dead-letters it
44+
#: directly (never retried, never decoded; the DLQ record is the incoming raw body). The
45+
#: rejected ``bq-schema-version`` is named in the logged error.
4546
UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
4647

4748
Headers = List[Tuple[str, bytes]]

‎src/babelqueue/pulsar_transport.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,9 @@
5959
#: ... that fits a signed 32-bit int, matching the Java and .NET consumers (GR-5).
6060
_MAX_ATTEMPTS = 2**31 - 1
6161
#: Prefix of the reserved ``route_urn`` a version-gated message is delivered under. It is not a
62-
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime reports it as an
63-
#: unknown URN, which names the rejected ``bq-schema-version``.
62+
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime dead-letters it
63+
#: directly (never retried, never decoded; the DLQ record is the incoming raw body). The
64+
#: rejected ``bq-schema-version`` is named in the logged error.
6465
UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
6566
#: Separates the rejected version from the delivery's original ``bq-job`` in a gate route.
6667
_GATE_JOB_PARAM = "?bq-job="

‎src/babelqueue/sqs_transport.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@
5454
#: U+0085, U+00A0, U+2028, U+3000 ...
5555
_BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
5656
#: Prefix of the reserved ``route_urn`` a version-gated message is delivered under. It is not a
57-
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime reports it as an
58-
#: unknown URN, which names the rejected ``bq-schema-version``.
57+
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime dead-letters it
58+
#: directly (never retried, never decoded; the DLQ record is the incoming raw body). The
59+
#: rejected ``bq-schema-version`` is named in the logged error.
5960
UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
6061

6162

‎tests/test_app.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -265,9 +265,11 @@ def test_version_gated_message_is_dead_lettered_directly_without_retry(self) ->
265265
self.assertEqual(seen, []) # never reaches the handler
266266
self.assertEqual(app.transport.size("orders"), 0)
267267
self.assertEqual(app.transport.size("orders.dlq"), 1)
268-
dead = EnvelopeCodec.decode(app.transport._queues["orders.dlq"][0]) # noqa: SLF001
269-
self.assertEqual(dead["dead_letter"]["reason"], "unknown_urn")
270-
self.assertIn(route, dead["dead_letter"]["error"])
268+
# The gate rejects without decoding: the DLQ record is the incoming raw body,
269+
# byte-identical, with no dead_letter block (even for an undecodable body).
270+
dead = app.transport._queues["orders.dlq"][0] # noqa: SLF001
271+
self.assertEqual(dead, body)
272+
self.assertNotIn("dead_letter", dead)
271273

272274
def test_version_gated_message_whose_dead_letter_write_fails_is_not_retried(self) -> None:
273275
from babelqueue.app import _UNSUPPORTED_SCHEMA_ROUTE_PREFIX

‎tests/test_artemis_transport.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,8 @@ def test_app_dead_letters_unsupported_version_without_reaching_a_handler(self) -
301301
# max_attempts > 1: a gated message is dead-lettered directly, never retried
302302
for value in ("2", "x", "2&x=%"):
303303
with self.subTest(value=value):
304-
transport, receiver = self._transport(body(), {"bq_schema_version": value})
304+
incoming = body()
305+
transport, receiver = self._transport(incoming, {"bq_schema_version": value})
305306
app = BabelQueue(transport=transport, max_attempts=5, dead_letter=True)
306307
seen: list = []
307308

@@ -318,10 +319,9 @@ def _on(data, meta):
318319
publish.assert_called_once()
319320
queue, dead_body = publish.call_args.args
320321
self.assertEqual("orders.dlq", queue)
321-
dead = EnvelopeCodec.decode(dead_body)
322-
self.assertEqual(URN, dead["job"]) # the producer's body, annotated
323-
self.assertEqual("unknown_urn", dead["dead_letter"]["reason"])
324-
self.assertIn(self.PREFIX + quote(value, safe=""), dead["dead_letter"]["error"])
322+
# rejected without decoding: the DLQ record is the incoming body, byte-identical
323+
self.assertEqual(incoming, dead_body)
324+
self.assertNotIn("dead_letter", dead_body)
325325

326326

327327
class ArtemisUrlTest(unittest.TestCase):

0 commit comments

Comments
 (0)