babelqueue 1.16.0__tar.gz → 1.16.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {babelqueue-1.16.0 → babelqueue-1.16.1}/.github/workflows/ci.yml +49 -1
  2. {babelqueue-1.16.0 → babelqueue-1.16.1}/CHANGELOG.md +21 -0
  3. {babelqueue-1.16.0 → babelqueue-1.16.1}/PKG-INFO +3 -3
  4. {babelqueue-1.16.0 → babelqueue-1.16.1}/README.md +2 -2
  5. {babelqueue-1.16.0 → babelqueue-1.16.1}/pyproject.toml +1 -1
  6. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/__init__.py +1 -1
  7. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/app.py +23 -8
  8. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/artemis_transport.py +3 -2
  9. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/asb_transport.py +3 -2
  10. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/kafka_transport.py +46 -6
  11. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/pulsar_transport.py +3 -2
  12. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/sqs_transport.py +3 -2
  13. babelqueue-1.16.1/tests/conformance/CONFORMANCE_VERSION +1 -0
  14. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/manifest.json +10 -6
  15. babelqueue-1.16.1/tests/fakes/confluent_kafka/__init__.py +73 -0
  16. babelqueue-1.16.1/tests/fakes/confluent_kafka/admin.py +20 -0
  17. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_app.py +5 -3
  18. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_artemis_transport.py +5 -5
  19. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_asb_conformance.py +3 -0
  20. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_asb_transport.py +5 -5
  21. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_kafka_conformance.py +2 -0
  22. babelqueue-1.16.1/tests/test_kafka_integration.py +169 -0
  23. babelqueue-1.16.1/tests/test_kafka_integration_logic.py +44 -0
  24. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_kafka_transport.py +162 -8
  25. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_pulsar_transport.py +5 -5
  26. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_sqs_transport.py +6 -8
  27. babelqueue-1.16.0/tests/conformance/CONFORMANCE_VERSION +0 -1
  28. {babelqueue-1.16.0 → babelqueue-1.16.1}/.github/FUNDING.yml +0 -0
  29. {babelqueue-1.16.0 → babelqueue-1.16.1}/.github/dependabot.yml +0 -0
  30. {babelqueue-1.16.0 → babelqueue-1.16.1}/.github/workflows/release.yml +0 -0
  31. {babelqueue-1.16.0 → babelqueue-1.16.1}/.gitignore +0 -0
  32. {babelqueue-1.16.0 → babelqueue-1.16.1}/LICENSE +0 -0
  33. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/celery.py +0 -0
  34. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/codec.py +0 -0
  35. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/contracts.py +0 -0
  36. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/dead_letter.py +0 -0
  37. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/django/__init__.py +0 -0
  38. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/django/apps.py +0 -0
  39. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/django/management/__init__.py +0 -0
  40. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/django/management/commands/__init__.py +0 -0
  41. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  42. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/exceptions.py +0 -0
  43. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/gdpr.py +0 -0
  44. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/headers.py +0 -0
  45. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/idempotency.py +0 -0
  46. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/otel.py +0 -0
  47. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/outbox.py +0 -0
  48. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/pika_transport.py +0 -0
  49. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/py.typed +0 -0
  50. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/redis_transport.py +0 -0
  51. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/redrive.py +0 -0
  52. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/replay.py +0 -0
  53. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/routing.py +0 -0
  54. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/schema.py +0 -0
  55. {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/transport.py +0 -0
  56. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/data-array-rejected.json +0 -0
  57. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/dead-lettered.json +0 -0
  58. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
  59. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
  60. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
  61. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
  62. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
  63. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
  64. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  65. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  66. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/order-created.json +0 -0
  67. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  68. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/unknown-lang.json +0 -0
  69. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
  70. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
  71. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/urn-alias.json +0 -0
  72. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/schema/message-envelope.schema.json +0 -0
  73. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/fixtures/dead-lettered.json +0 -0
  74. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/fixtures/order-created.json +0 -0
  75. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_artemis_conformance.py +0 -0
  76. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_behaviour_conformance.py +0 -0
  77. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_celery.py +0 -0
  78. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_codec.py +0 -0
  79. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_conformance.py +0 -0
  80. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_dead_letter.py +0 -0
  81. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_django.py +0 -0
  82. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_gdpr.py +0 -0
  83. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_headers.py +0 -0
  84. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_idempotency.py +0 -0
  85. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_otel.py +0 -0
  86. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_outbox.py +0 -0
  87. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_overhead.py +0 -0
  88. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_pika_transport.py +0 -0
  89. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_pulsar_conformance.py +0 -0
  90. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_redis_transport.py +0 -0
  91. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_redrive.py +0 -0
  92. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_replay.py +0 -0
  93. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_schema.py +0 -0
  94. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_schema_conformance.py +0 -0
  95. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_shutdown.py +0 -0
  96. {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_sqs_conformance.py +0 -0
@@ -4,6 +4,7 @@ on:
4
4
  push:
5
5
  branches: [main]
6
6
  pull_request:
7
+ workflow_dispatch:
7
8
 
8
9
  permissions:
9
10
  contents: read
@@ -111,6 +112,53 @@ jobs:
111
112
  AWS_SECRET_ACCESS_KEY: test
112
113
  run: pytest --cov=babelqueue --cov-report=term-missing --cov-fail-under=90
113
114
 
115
+ integration-live:
116
+ name: Integration (live Kafka)
117
+ # Opt-in, not a required check: runs on push to main and on demand, never on pull requests,
118
+ # and is deliberately NOT in ci-green's needs. (The `integration` job above is the required,
119
+ # coverage-gated one for Redis / RabbitMQ / SQS.)
120
+ if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
121
+ runs-on: ubuntu-latest
122
+ timeout-minutes: 20
123
+ services:
124
+ kafka:
125
+ image: apache/kafka:3.9.0
126
+ ports:
127
+ - 9092:9092
128
+ env:
129
+ KAFKA_NODE_ID: 1
130
+ KAFKA_PROCESS_ROLES: broker,controller
131
+ KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
132
+ KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
133
+ KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
134
+ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
135
+ KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
136
+ KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
137
+ KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
138
+ KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
139
+ KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
140
+ options: >-
141
+ --health-cmd "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092"
142
+ --health-interval 10s --health-timeout 10s --health-retries 12 --health-start-period 20s
143
+ steps:
144
+ - uses: actions/checkout@v7
145
+
146
+ - name: Setup Python
147
+ uses: actions/setup-python@v7
148
+ with:
149
+ python-version: '3.12'
150
+
151
+ - name: Install (kafka extra + dev)
152
+ run: |
153
+ python -m pip install --upgrade pip
154
+ pip install -e ".[kafka,dev]"
155
+
156
+ - name: Live Kafka tests
157
+ env:
158
+ BQ_INTEGRATION: "1"
159
+ BQ_KAFKA_BROKERS: localhost:9092
160
+ run: pytest tests/test_kafka_integration.py -v
161
+
114
162
  conformance:
115
163
  name: Conformance suite in sync
116
164
  runs-on: ubuntu-latest
@@ -118,7 +166,7 @@ jobs:
118
166
  - uses: actions/checkout@v7
119
167
  - name: Verify vendored conformance matches the canonical suite
120
168
  run: |
121
- git clone --depth 1 --branch v1.1.0 https://github.com/BabelQueue/conformance.git "$RUNNER_TEMP/conformance"
169
+ git clone --depth 1 --branch v1.2.0 https://github.com/BabelQueue/conformance.git "$RUNNER_TEMP/conformance"
122
170
  diff -u "$RUNNER_TEMP/conformance/CONFORMANCE_VERSION" "tests/conformance/CONFORMANCE_VERSION"
123
171
  diff -ru "$RUNNER_TEMP/conformance/manifest.json" "tests/conformance/manifest.json"
124
172
  diff -ru "$RUNNER_TEMP/conformance/fixtures" "tests/conformance/fixtures"
@@ -9,6 +9,27 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.16.1] - 2026-10-11
13
+
14
+ ### Changed
15
+ - **Schema-version gate rejection now dead-letters the incoming body, byte-identical.** A message
16
+ whose `bq-schema-version` (`bq_schema_version` on Artemis) is unsupported is rejected *without
17
+ decoding it* (broker-bindings §3.7/§4.7/§5.7/§6.7/§7.7) and, when the DLQ is enabled, the DLQ
18
+ record is the raw body as received, with no `dead_letter` block. Previously the core decoded
19
+ the envelope and wrote an annotated `dead_letter{reason:"unknown_urn"}` record. This aligns the
20
+ Python SDK with Java, .NET and Node. A DLQ consumer that expected the annotation for gated
21
+ messages is affected; a failed DLQ write still leaves the message unsettled (rewound on Kafka),
22
+ and DLQ off / `UnknownUrnStrategy.DELETE` still drops it. No wire or API change.
23
+
24
+ ### Fixed
25
+ - **Kafka: a record whose settlement raised was silently lost on a consume-loop restart.** When a
26
+ retry / release / dead-letter `publish` failed (or the loop was otherwise interrupted before
27
+ `ack`), `consume()` ended with the record neither acked nor rewound while `poll` had already
28
+ advanced the position; the restarted loop received the *next* record and acking it committed
29
+ past the unsettled one (Kafka offsets are cumulative). `KafkaTransport` now remembers the
30
+ record `pop` handed out and re-seeks it on the next `pop` unless it was acked or rewound.
31
+ No API or wire change.
32
+
12
33
  ## [1.16.0] - 2026-10-10
13
34
 
14
35
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: babelqueue
3
- Version: 1.16.0
3
+ Version: 1.16.1
4
4
  Summary: Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers.
5
5
  Project-URL: Homepage, https://babelqueue.com
6
6
  Project-URL: Source, https://github.com/BabelQueue/babelqueue-python
@@ -186,8 +186,8 @@ ASCII whitespace) follows the normal path; any other value must be exactly `"1"`
186
186
  untrimmed; Azure Service Bus and Artemis also accept the integer `1`). A message carrying
187
187
  anything else is not decoded: it reaches the runtime on the reserved route
188
188
  `babelqueue:unsupported-schema-version:<v>` (the version percent-escaped) and is **never
189
- retried** — it goes straight to the dead-letter queue (reason `unknown_urn`, the body kept
190
- verbatim) when `dead_letter` is enabled, and is dropped otherwise, whatever `max_attempts` is.
189
+ retried** — it goes straight to the dead-letter queue (the incoming body, byte-identical,
190
+ with no `dead_letter` block) when `dead_letter` is enabled, and is dropped otherwise, whatever `max_attempts` is.
191
191
  It does not depend on a broker `RedrivePolicy`. If the dead-letter write fails the message is
192
192
  left unacknowledged (an error is logged), is never retried and never reaches a handler; the
193
193
  dead-letter write is attempted again when the broker brings it back, which depends on the
@@ -132,8 +132,8 @@ ASCII whitespace) follows the normal path; any other value must be exactly `"1"`
132
132
  untrimmed; Azure Service Bus and Artemis also accept the integer `1`). A message carrying
133
133
  anything else is not decoded: it reaches the runtime on the reserved route
134
134
  `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.
137
137
  It does not depend on a broker `RedrivePolicy`. If the dead-letter write fails the message is
138
138
  left unacknowledged (an error is logged), is never retried and never reaches a handler; the
139
139
  dead-letter write is attempted again when the broker brings it back, which depends on the
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "1.16.0"
7
+ version = "1.16.1"
8
8
  description = "Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -39,7 +39,7 @@ from .transport import (
39
39
  Transport,
40
40
  )
41
41
 
42
- __version__ = "1.16.0"
42
+ __version__ = "1.16.1"
43
43
 
44
44
  __all__ = [
45
45
  "BabelQueue",
@@ -279,7 +279,11 @@ class BabelQueue:
279
279
  with _headers_scope(received.headers), _replay_scope(
280
280
  bool(received.headers.get(HEADER_REPLAY_BYPASS))
281
281
  ):
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)
283
287
  urn = str(envelope.get("job") or envelope.get("urn") or "")
284
288
  if received.route_urn and envelope:
285
289
  # Transport-supplied routing override; body untouched. Only for a body that
@@ -479,7 +483,7 @@ class BabelQueue:
479
483
  # gate would be bypassed or the message would loop. It goes straight to the DLQ (or is
480
484
  # dropped when no DLQ is configured).
481
485
  if received.route_urn.startswith(_UNSUPPORTED_SCHEMA_ROUTE_PREFIX):
482
- self._settle_version_gated(received, envelope)
486
+ self._settle_version_gated(received)
483
487
  return
484
488
  # An undecodable body or a body naming no URN can never gain a handler either, so
485
489
  # releasing it unchanged would redeliver it forever. It takes the bounded FAIL path
@@ -495,11 +499,21 @@ class BabelQueue:
495
499
  # (never kills the loop).
496
500
  raise UnknownUrnError(f"No handler mapped for URN [{urn or '(empty)'}].")
497
501
 
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
+ """
500
514
  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)
503
517
  return
504
518
  self._ack_settled(received)
505
519
 
@@ -537,8 +551,9 @@ class BabelQueue:
537
551
  error=(str(exc) if exc is not None else None),
538
552
  exception=(type(exc).__name__ if exc is not None else None),
539
553
  )
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
+ )
542
557
  self._ack_settled(received)
543
558
 
544
559
 
@@ -47,8 +47,9 @@ _logger = logging.getLogger("babelqueue.artemis")
47
47
  #: U+0085, U+00A0, U+2028, U+3000 ...
48
48
  _BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
49
49
  #: 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.
52
53
  UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
53
54
 
54
55
  JMS_TYPE_ANNOTATION = "x-opt-jms-type"
@@ -38,8 +38,9 @@ _logger = logging.getLogger("babelqueue.asb")
38
38
  #: U+0085, U+00A0, U+2028, U+3000 ...
39
39
  _BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
40
40
  #: 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.
43
44
  UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
44
45
 
45
46
 
@@ -8,7 +8,9 @@ envelope fields onto native Kafka record headers (UTF-8 byte strings): ``bq-job`
8
8
  ``bq-source-lang`` / ``bq-attempts`` — so a Java/.NET/... peer can route on ``bq-job``
9
9
  without parsing the body — with the record timestamp mirroring ``meta.created_at``.
10
10
  Consuming is process-then-commit: ``poll`` reserves a record (``enable.auto.commit=false``)
11
- and ``ack`` commits the offset only after the handler returns (at-least-once). Kafka has no
11
+ and ``ack`` commits the offset only after the handler returns (at-least-once). A record that is
12
+ neither acked nor rewound (its settlement raised) is re-sought by the next ``pop``, so a
13
+ restarted consume loop redelivers it instead of committing past it. Kafka has no
12
14
  native delivery count, so the ``bq-attempts`` header is the authoritative retry counter (the
13
15
  body's ``attempts`` is the fallback for non-BabelQueue producers); the runtime owns retry by
14
16
  republishing with attempts+1 and dead-letters to ``<queue>.dlq``.
@@ -38,8 +40,9 @@ _logger = logging.getLogger("babelqueue.kafka")
38
40
  #: U+0085, U+00A0, U+2028, U+3000 ...
39
41
  _BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
40
42
  #: 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``.
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.
43
46
  UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
44
47
 
45
48
  Headers = List[Tuple[str, bytes]]
@@ -66,6 +69,8 @@ class KafkaTransport(Transport):
66
69
  self._producer = producer
67
70
  self._consumer_factory = consumer_factory
68
71
  self._consumers: Dict[str, Any] = {}
72
+ # Per queue: the record handed out by ``pop`` that has been neither acked nor rewound yet.
73
+ self._pending: Dict[str, Any] = {}
69
74
 
70
75
  # -- helpers ------------------------------------------------------------
71
76
 
@@ -197,6 +202,7 @@ class KafkaTransport(Transport):
197
202
 
198
203
  def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
199
204
  wait = timeout if timeout and timeout > 0 else 1.0
205
+ self._rewind_abandoned(queue)
200
206
  message = self._consumer(queue).poll(wait)
201
207
  if message is None or message.error() is not None:
202
208
  return None
@@ -215,18 +221,27 @@ class KafkaTransport(Transport):
215
221
  unsupported,
216
222
  SCHEMA_VERSION,
217
223
  )
218
- return ReceivedMessage(
224
+ received = ReceivedMessage(
219
225
  body=self._payload(message),
220
226
  queue=queue,
221
227
  handle=message,
222
228
  route_urn=UNSUPPORTED_SCHEMA_ROUTE_PREFIX + quote(unsupported, safe=""),
223
229
  )
230
+ self._pending[queue] = message
231
+ return received
224
232
  body = self._reconcile(self._payload(message), headers)
225
- return ReceivedMessage(body=body, queue=queue, handle=message)
233
+ received = ReceivedMessage(body=body, queue=queue, handle=message)
234
+ # Track the record as in flight only once ``pop`` has built it. A decode error above
235
+ # escapes before this point and must not leave the record pending: the next ``pop`` would
236
+ # seek back to it and fail identically, wedging the partition forever.
237
+ self._pending[queue] = message
238
+ return received
226
239
 
227
240
  def ack(self, message: ReceivedMessage) -> None:
228
241
  if message.handle is None:
229
242
  return
243
+ # Settled: from here on the record is no longer "in flight", whatever the commit does.
244
+ self._pending.pop(message.queue, None)
230
245
  self._consumer(message.queue).commit(message=message.handle, asynchronous=False)
231
246
 
232
247
  def rewind(self, message: ReceivedMessage) -> None:
@@ -236,6 +251,31 @@ class KafkaTransport(Transport):
236
251
  handle = message.handle
237
252
  if handle is None:
238
253
  return
254
+ self._seek_back(message.queue, handle)
255
+ if self._pending.get(message.queue) is handle:
256
+ del self._pending[message.queue]
257
+
258
+ def _rewind_abandoned(self, queue: str) -> None:
259
+ """Re-seek a record that ``pop`` handed out but that was never acked or rewound.
260
+
261
+ That happens when the runtime's settlement raised (a failed retry / DLQ / release
262
+ ``publish``): ``poll`` has already advanced the in-memory position, so without this the
263
+ next ``pop`` (a restarted consume loop) would return the *following* record, and acking it
264
+ would commit past the unsettled one — losing it. Seeking back makes it redeliver instead.
265
+ """
266
+ handle = self._pending.get(queue)
267
+ if handle is None:
268
+ return
269
+ try:
270
+ self._seek_back(queue, handle)
271
+ except Exception: # noqa: BLE001 - already logged, naming the record
272
+ # A seek can only fail here for a partition that is no longer assigned (rebalance); the
273
+ # group then resets the position to the committed offset, which is that record. Do not
274
+ # wedge the consumer on it.
275
+ pass
276
+ self._pending.pop(queue, None)
277
+
278
+ def _seek_back(self, queue: str, handle: Any) -> None:
239
279
  try:
240
280
  from confluent_kafka import TopicPartition
241
281
  except ImportError: # only reachable with an injected consumer
@@ -244,7 +284,7 @@ class KafkaTransport(Transport):
244
284
  TopicPartition = namedtuple("TopicPartition", "topic partition offset") # type: ignore[misc]
245
285
  topic, partition, offset = handle.topic(), handle.partition(), handle.offset()
246
286
  try:
247
- self._consumer(message.queue).seek(TopicPartition(topic, partition, offset))
287
+ self._consumer(queue).seek(TopicPartition(topic, partition, offset))
248
288
  except Exception:
249
289
  # Name the record so an operator can recover it by hand: if the seek fails the
250
290
  # commit of a later record on this partition may skip it.
@@ -59,8 +59,9 @@ _BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
59
59
  #: ... that fits a signed 32-bit int, matching the Java and .NET consumers (GR-5).
60
60
  _MAX_ATTEMPTS = 2**31 - 1
61
61
  #: 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.
64
65
  UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
65
66
  #: Separates the rejected version from the delivery's original ``bq-job`` in a gate route.
66
67
  _GATE_JOB_PARAM = "?bq-job="
@@ -54,8 +54,9 @@ logger = logging.getLogger("babelqueue.sqs")
54
54
  #: U+0085, U+00A0, U+2028, U+3000 ...
55
55
  _BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
56
56
  #: 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.
59
60
  UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
60
61
 
61
62
 
@@ -222,7 +222,7 @@
222
222
  ]
223
223
  },
224
224
  "schema_version_gate": {
225
- "description": "Consumer-side schema-version gate (broker-bindings.md §4.7). The consumer MUST read the ASB ApplicationProperties entry `bq-schema-version` BEFORE decoding the body. Blank means the empty string or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) - the same set in every SDK (GR-5): an absent or blank value falls through to the normal decode path (the body meta.schema_version check still applies afterwards). Otherwise the value is compared as-is, never trimmed: exactly \"1\" decodes, and anything else is rejected WITHOUT decoding (poison/DLQ path) - including \"2\", \"01\", \"x\", values padded with whitespace (\" 1\", \"1 \"), and values made of non-ASCII whitespace (NBSP U+00A0, U+0085, U+3000) or a BOM (U+FEFF), which are NOT blank. A unicode-aware blank check or a trim() before comparing fails these cases. Each case supplies the property 'value' (or 'absent': true) and the 'expect'ed verdict; the message body is always the valid envelope in 'fixture', so a 'reject' verdict can only come from the property gate. ASB application properties are AMQP-typed, so the JSON-number cases (a number, not a string) stand for a native AMQP number: any AMQP integral type (byte/short/int/long, signed or unsigned) with value 1 decodes; any other numeric value (2, 0, non-integral 1.5) and any other type rejects. JSON numbers carry no width, so a runner MUST exercise integral 1 and 2 at both int32 and int64 width. A floating-point 1.0 is unspecified and not tested.",
225
+ "description": "Consumer-side schema-version gate (broker-bindings.md §4.7). The consumer MUST read the ASB ApplicationProperties entry `bq-schema-version` BEFORE decoding the body. Blank means the empty string or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) - the same set in every SDK (GR-5): an absent or blank value falls through to the normal decode path (the body meta.schema_version check still applies afterwards). Otherwise the value is compared as-is, never trimmed: exactly \"1\" decodes, and anything else is rejected WITHOUT decoding (poison/DLQ path) - including \"2\", \"01\", \"x\", values padded with whitespace (\" 1\", \"1 \"), and values made of non-ASCII whitespace (NBSP U+00A0, U+0085, U+3000) or a BOM (U+FEFF), which are NOT blank. A unicode-aware blank check or a trim() before comparing fails these cases. Each case supplies the property 'value' (or 'absent': true) and the 'expect'ed verdict; the message body is always the valid envelope in 'fixture', so a 'reject' verdict can only come from the property gate. ASB application properties are AMQP-typed, so the JSON-number cases (a number, not a string) stand for a native AMQP number: any AMQP integral type (byte/short/int/long, signed or unsigned) with value 1 decodes; any other numeric value (2, 0, non-integral 1.5) and any other type rejects. JSON numbers carry no width, so a runner MUST exercise integral 1 and 2 at both int32 and int64 width. A floating-point 1.0 is a native AMQP float/double, NOT integral 1, and rejects: because JSON parsers in several languages (Go, Node) cannot tell 1.0 from 1, that case carries an explicit 'value_type': 'float' marker and a runner MUST hand the SDK a real floating-point value (float32 and float64 where the language has both), never an integer. A language with a single number type (JavaScript: AMQP float 1.0 arrives as the number 1) cannot represent the case and its runner skips it explicitly. A case without 'value_type' keeps the plain meaning above. A JSON null 'value' stands for a present-but-null application property: it is treated exactly like an absent one (the property is simply not set) and decodes, so the runner MUST put the property key into the message with a null value rather than omit it.",
226
226
  "property": "bq-schema-version",
227
227
  "fixture": "fixtures/order-created.json",
228
228
  "cases": [
@@ -247,7 +247,9 @@
247
247
  { "value": 1, "expect": "decode" },
248
248
  { "value": 2, "expect": "reject" },
249
249
  { "value": 0, "expect": "reject" },
250
- { "value": 1.5, "expect": "reject" }
250
+ { "value": 1.5, "expect": "reject" },
251
+ { "value": 1.0, "value_type": "float", "expect": "reject" },
252
+ { "value": null, "expect": "decode" }
251
253
  ]
252
254
  }
253
255
  },
@@ -327,7 +329,7 @@
327
329
  ]
328
330
  },
329
331
  "schema_version_gate": {
330
- "description": "Consumer-side schema-version gate (broker-bindings.md §6.7). The consumer MUST read the Kafka record header `bq-schema-version` BEFORE decoding the body. Blank means the empty string or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) - the same set in every SDK (GR-5): an absent or blank value falls through to the normal decode path (the body meta.schema_version check still applies afterwards). Otherwise the value is compared as-is, never trimmed: exactly \"1\" decodes, and anything else is rejected WITHOUT decoding (poison/DLQ path) - including \"2\", \"01\", \"x\", values padded with whitespace (\" 1\", \"1 \"), and values made of non-ASCII whitespace (NBSP U+00A0, U+0085, U+3000) or a BOM (U+FEFF), which are NOT blank. A unicode-aware blank check or a trim() before comparing fails these cases. Each case supplies the property 'value' (or 'absent': true) and the 'expect'ed verdict; the message body is always the valid envelope in 'fixture', so a 'reject' verdict can only come from the property gate.",
332
+ "description": "Consumer-side schema-version gate (broker-bindings.md §6.7). The consumer MUST read the Kafka record header `bq-schema-version` BEFORE decoding the body. Blank means the empty string or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) - the same set in every SDK (GR-5): an absent or blank value falls through to the normal decode path (the body meta.schema_version check still applies afterwards). Otherwise the value is compared as-is, never trimmed: exactly \"1\" decodes, and anything else is rejected WITHOUT decoding (poison/DLQ path) - including \"2\", \"01\", \"x\", values padded with whitespace (\" 1\", \"1 \"), and values made of non-ASCII whitespace (NBSP U+00A0, U+0085, U+3000) or a BOM (U+FEFF), which are NOT blank. A unicode-aware blank check or a trim() before comparing fails these cases. Each case supplies the property 'value' (or 'absent': true) and the 'expect'ed verdict; the message body is always the valid envelope in 'fixture', so a 'reject' verdict can only come from the property gate. A JSON null 'value' stands for a present-but-null Kafka record header (Kafka allows a null header value): it is treated exactly like an absent one (the header carries no version) and decodes, so the runner MUST put the header key into the record with a null value rather than omit it, and an SDK MUST NOT stringify the null (a header whose value became the text \"null\" would be rejected).",
331
333
  "property": "bq-schema-version",
332
334
  "fixture": "fixtures/order-created.json",
333
335
  "cases": [
@@ -348,7 +350,8 @@
348
350
  { "value": "\u3000", "expect": "reject" },
349
351
  { "value": "\ufeff", "expect": "reject" },
350
352
  { "value": "\u00a01", "expect": "reject" },
351
- { "value": "1\u3000", "expect": "reject" }
353
+ { "value": "1\u3000", "expect": "reject" },
354
+ { "value": null, "expect": "decode" }
352
355
  ]
353
356
  }
354
357
  },
@@ -379,7 +382,7 @@
379
382
  ]
380
383
  },
381
384
  "schema_version_gate": {
382
- "description": "Consumer-side schema-version gate (broker-bindings.md §7.7). The consumer MUST read the Artemis application property `bq_schema_version` (underscores, ADR-0017) BEFORE decoding the body. Blank means the empty string or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) - the same set in every SDK (GR-5): an absent or blank value falls through to the normal decode path (the body meta.schema_version check still applies afterwards). Otherwise the value is compared as-is, never trimmed: exactly \"1\" decodes, and anything else is rejected WITHOUT decoding (poison/DLQ path) - including \"2\", \"01\", \"x\", values padded with whitespace (\" 1\", \"1 \"), and values made of non-ASCII whitespace (NBSP U+00A0, U+0085, U+3000) or a BOM (U+FEFF), which are NOT blank. A unicode-aware blank check or a trim() before comparing fails these cases. Each case supplies the property 'value' (or 'absent': true) and the 'expect'ed verdict; the message body is always the valid envelope in 'fixture', so a 'reject' verdict can only come from the property gate.",
385
+ "description": "Consumer-side schema-version gate (broker-bindings.md §7.7). The consumer MUST read the Artemis application property `bq_schema_version` (underscores, ADR-0017) BEFORE decoding the body. Blank means the empty string or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) - the same set in every SDK (GR-5): an absent or blank value falls through to the normal decode path (the body meta.schema_version check still applies afterwards). Otherwise the value is compared as-is, never trimmed: exactly \"1\" decodes, and anything else is rejected WITHOUT decoding (poison/DLQ path) - including \"2\", \"01\", \"x\", values padded with whitespace (\" 1\", \"1 \"), and values made of non-ASCII whitespace (NBSP U+00A0, U+0085, U+3000) or a BOM (U+FEFF), which are NOT blank. A unicode-aware blank check or a trim() before comparing fails these cases. Each case supplies the property 'value' (or 'absent': true) and the 'expect'ed verdict; the message body is always the valid envelope in 'fixture', so a 'reject' verdict can only come from the property gate. A JSON null 'value' stands for a present-but-null application property: it is treated exactly like an absent one (the property carries no version) and decodes, so the runner MUST put the property key into the message with a null value rather than omit it. A reader whose message API cannot carry a null application property MUST skip this case explicitly (and assert that it skips) rather than silently drop it.",
383
386
  "property": "bq_schema_version",
384
387
  "fixture": "fixtures/order-created.json",
385
388
  "cases": [
@@ -400,7 +403,8 @@
400
403
  { "value": "\u3000", "expect": "reject" },
401
404
  { "value": "\ufeff", "expect": "reject" },
402
405
  { "value": "\u00a01", "expect": "reject" },
403
- { "value": "1\u3000", "expect": "reject" }
406
+ { "value": "1\u3000", "expect": "reject" },
407
+ { "value": null, "expect": "decode" }
404
408
  ]
405
409
  }
406
410
  },
@@ -0,0 +1,73 @@
1
+ """In-process stand-in for the slice of confluent_kafka the live tests and KafkaTransport use.
2
+
3
+ Single partition per topic, per-group committed offsets, cumulative commit, seek, and
4
+ position-from-committed on a new consumer — the semantics the transport relies on."""
5
+ import threading
6
+
7
+ _LOCK = threading.RLock()
8
+ TOPICS = {} # topic -> list[Message]
9
+ COMMITTED = {} # (group, topic) -> next offset to read
10
+
11
+
12
+ class TopicPartition:
13
+ def __init__(self, topic, partition=0, offset=-1001):
14
+ self.topic, self.partition, self.offset = topic, partition, offset
15
+
16
+
17
+ class Message:
18
+ def __init__(self, topic, offset, value, headers):
19
+ self._t, self._o, self._v, self._h = topic, offset, value, headers
20
+
21
+ def value(self): return self._v
22
+ def headers(self): return self._h
23
+ def error(self): return None
24
+ def topic(self): return self._t
25
+ def partition(self): return 0
26
+ def offset(self): return self._o
27
+
28
+
29
+ class Producer:
30
+ def __init__(self, conf): pass
31
+
32
+ def produce(self, topic, value=None, headers=None, timestamp=None, **kw):
33
+ with _LOCK:
34
+ log = TOPICS.setdefault(topic, []) # auto-create, like the real broker default
35
+ log.append(Message(topic, len(log), value, list(headers or [])))
36
+
37
+ def poll(self, t): return 0
38
+ def flush(self, *a): return 0
39
+
40
+
41
+ class Consumer:
42
+ def __init__(self, conf):
43
+ self.group = conf["group.id"]
44
+ self.topic = None
45
+ self.position = None
46
+
47
+ def subscribe(self, topics):
48
+ self.topic = topics[0]
49
+ self.position = COMMITTED.get((self.group, self.topic), 0)
50
+
51
+ def poll(self, timeout=None):
52
+ with _LOCK:
53
+ log = TOPICS.get(self.topic, [])
54
+ if self.position < len(log):
55
+ m = log[self.position]
56
+ self.position += 1
57
+ return m
58
+ import time
59
+ time.sleep(min(timeout or 0.05, 0.05))
60
+ return None
61
+
62
+ def commit(self, message=None, asynchronous=True):
63
+ with _LOCK:
64
+ key = (self.group, message.topic())
65
+ COMMITTED[key] = max(COMMITTED.get(key, 0), message.offset() + 1)
66
+
67
+ def seek(self, tp):
68
+ self.position = tp.offset
69
+
70
+ def committed(self, partitions, timeout=None):
71
+ return [TopicPartition(p.topic, p.partition, COMMITTED.get((self.group, p.topic), -1001)) for p in partitions]
72
+
73
+ def close(self): pass
@@ -0,0 +1,20 @@
1
+ import concurrent.futures
2
+ from . import TOPICS, _LOCK
3
+
4
+
5
+ class NewTopic:
6
+ def __init__(self, name, **kw): self.name = name
7
+
8
+
9
+ class AdminClient:
10
+ def __init__(self, conf): pass
11
+
12
+ def create_topics(self, topics):
13
+ out = {}
14
+ for t in topics:
15
+ with _LOCK:
16
+ TOPICS.setdefault(t.name, [])
17
+ f = concurrent.futures.Future()
18
+ f.set_result(None)
19
+ out[t.name] = f
20
+ return out
@@ -265,9 +265,11 @@ class AppTest(unittest.TestCase):
265
265
  self.assertEqual(seen, []) # never reaches the handler
266
266
  self.assertEqual(app.transport.size("orders"), 0)
267
267
  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)
271
273
 
272
274
  def test_version_gated_message_whose_dead_letter_write_fails_is_not_retried(self) -> None:
273
275
  from babelqueue.app import _UNSUPPORTED_SCHEMA_ROUTE_PREFIX
@@ -301,7 +301,8 @@ class ArtemisSchemaVersionGateTest(unittest.TestCase):
301
301
  # max_attempts > 1: a gated message is dead-lettered directly, never retried
302
302
  for value in ("2", "x", "2&x=%"):
303
303
  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})
305
306
  app = BabelQueue(transport=transport, max_attempts=5, dead_letter=True)
306
307
  seen: list = []
307
308
 
@@ -318,10 +319,9 @@ class ArtemisSchemaVersionGateTest(unittest.TestCase):
318
319
  publish.assert_called_once()
319
320
  queue, dead_body = publish.call_args.args
320
321
  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)
325
325
 
326
326
 
327
327
  class ArtemisUrlTest(unittest.TestCase):
@@ -65,6 +65,9 @@ class AsbConformanceTest(unittest.TestCase):
65
65
  self.assertEqual(EnvelopeCodec.decode(valid_body)["job"], "urn:babel:orders:created")
66
66
 
67
67
  for case in gate["cases"]:
68
+ if case.get("value_type") == "float":
69
+ # An explicit floating-point case (e.g. 1.0): the reader must hand over a real float.
70
+ self.assertIsInstance(case["value"], float, "schema_version_gate case %r" % (case,))
68
71
  carrier = self._gate_carrier(case)
69
72
  rejected = AsbTransport._unsupported_schema_version(carrier)
70
73
  want = "reject" if case["expect"] == "reject" else "decode"
@@ -277,7 +277,8 @@ class AsbSchemaVersionGateTest(unittest.TestCase):
277
277
  # max_attempts > 1: a gated message is dead-lettered directly, never retried
278
278
  for value in ("2", "x", 2, "2&x=%"):
279
279
  with self.subTest(value=value):
280
- receiver = FakeReceiver([FakeReceived(body(), 1, {"bq-schema-version": value})])
280
+ incoming = body()
281
+ receiver = FakeReceiver([FakeReceived(incoming, 1, {"bq-schema-version": value})])
281
282
  app = BabelQueue(
282
283
  transport=AsbTransport(client=FakeClient(receiver=receiver)),
283
284
  max_attempts=5,
@@ -298,10 +299,9 @@ class AsbSchemaVersionGateTest(unittest.TestCase):
298
299
  publish.assert_called_once()
299
300
  queue, dead_body = publish.call_args.args
300
301
  self.assertEqual(queue, "orders.dlq")
301
- dead = EnvelopeCodec.decode(dead_body)
302
- self.assertEqual(dead["job"], URN) # the producer's body, annotated
303
- self.assertEqual(dead["dead_letter"]["reason"], "unknown_urn")
304
- self.assertIn(self.PREFIX + quote(str(value), safe=""), dead["dead_letter"]["error"])
302
+ # rejected without decoding: the DLQ record is the incoming body, byte-identical
303
+ self.assertEqual(dead_body, incoming)
304
+ self.assertNotIn("dead_letter", dead_body)
305
305
 
306
306
 
307
307
  @unittest.skipUnless(HAVE_ASB, "azure-servicebus not installed")
@@ -52,6 +52,8 @@ class KafkaConformanceTest(unittest.TestCase):
52
52
  def _gate_carrier(self, case: dict):
53
53
  if "absent" in case:
54
54
  return []
55
+ if case["value"] is None: # JSON null: header present with a null value == absent (§6.7)
56
+ return [(self.gate["property"], None)]
55
57
  return [(self.gate["property"], case["value"].encode("utf-8"))]
56
58
 
57
59
  def test_schema_version_gate(self) -> None: