babelqueue 1.22.0__tar.gz → 1.23.0__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 (147) hide show
  1. {babelqueue-1.22.0 → babelqueue-1.23.0}/.github/workflows/ci.yml +1 -1
  2. {babelqueue-1.22.0 → babelqueue-1.23.0}/.github/workflows/release.yml +2 -2
  3. {babelqueue-1.22.0 → babelqueue-1.23.0}/CHANGELOG.md +70 -0
  4. {babelqueue-1.22.0 → babelqueue-1.23.0}/PKG-INFO +26 -4
  5. {babelqueue-1.22.0 → babelqueue-1.23.0}/README.md +25 -3
  6. {babelqueue-1.22.0 → babelqueue-1.23.0}/pyproject.toml +1 -1
  7. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/__init__.py +1 -1
  8. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/_envelope_shape.py +1 -1
  9. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/app.py +117 -18
  10. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/artemis_transport.py +2 -1
  11. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/codec.py +46 -1
  12. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/kafka_transport.py +8 -2
  13. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/pika_transport.py +3 -1
  14. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/pulsar_transport.py +35 -2
  15. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/redis_transport.py +23 -13
  16. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/sqs_transport.py +11 -0
  17. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/transport.py +14 -0
  18. babelqueue-1.23.0/tests/conformance/CONFORMANCE_VERSION +1 -0
  19. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/manifest.json +818 -1
  20. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/schema/results.example.json +1 -1
  21. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_app.py +23 -4
  22. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_behaviour_conformance.py +176 -3
  23. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_conformance.py +2 -0
  24. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_kafka_retry.py +56 -0
  25. babelqueue-1.23.0/tests/test_poison_routing.py +511 -0
  26. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_pulsar_transport.py +4 -0
  27. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_redis_transport.py +92 -5
  28. babelqueue-1.22.0/tests/conformance/CONFORMANCE_VERSION +0 -1
  29. {babelqueue-1.22.0 → babelqueue-1.23.0}/.github/FUNDING.yml +0 -0
  30. {babelqueue-1.22.0 → babelqueue-1.23.0}/.github/dependabot.yml +0 -0
  31. {babelqueue-1.22.0 → babelqueue-1.23.0}/.gitignore +0 -0
  32. {babelqueue-1.22.0 → babelqueue-1.23.0}/LICENSE +0 -0
  33. {babelqueue-1.22.0 → babelqueue-1.23.0}/conformance-known-gaps.json +0 -0
  34. {babelqueue-1.22.0 → babelqueue-1.23.0}/scripts/check_conformance_results.py +0 -0
  35. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/_poison_record.py +0 -0
  36. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/asb_transport.py +0 -0
  37. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/celery.py +0 -0
  38. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/contracts.py +0 -0
  39. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/dead_letter.py +0 -0
  40. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/django/__init__.py +0 -0
  41. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/django/apps.py +0 -0
  42. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/django/management/__init__.py +0 -0
  43. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  44. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  45. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/exceptions.py +0 -0
  46. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/gdpr.py +0 -0
  47. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/headers.py +0 -0
  48. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/idempotency.py +0 -0
  49. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/kafka_retry.py +0 -0
  50. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/otel.py +0 -0
  51. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/outbox.py +0 -0
  52. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/py.typed +0 -0
  53. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/redrive.py +0 -0
  54. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/replay.py +0 -0
  55. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/routing.py +0 -0
  56. {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/schema.py +0 -0
  57. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/blank-ascii-job-urn-fallback.json +0 -0
  58. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
  59. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/dead-letter-roundtrip.json +0 -0
  60. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  61. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/duplicate-keys-last-wins.json +0 -0
  62. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
  63. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-job-and-urn.json +0 -0
  64. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-job-nonascii-space-and-urn.json +0 -0
  65. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
  66. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
  67. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
  68. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
  69. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
  70. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  71. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  72. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/invalid-wrong-case-job.json +0 -0
  73. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-array-urn-fallback.json +0 -0
  74. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-bool-urn-fallback.json +0 -0
  75. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-empty-string-urn-fallback.json +0 -0
  76. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-missing-job-array-urn.json +0 -0
  77. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-null-no-urn.json +0 -0
  78. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-null-urn-fallback.json +0 -0
  79. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-number-blank-urn.json +0 -0
  80. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-number-nonstring-urn.json +0 -0
  81. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-number-urn-fallback.json +0 -0
  82. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-object-urn-fallback.json +0 -0
  83. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/meta-ext-vendor-roundtrip.json +0 -0
  84. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/order-created.json +0 -0
  85. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-cr.json +0 -0
  86. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-ff.json +0 -0
  87. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-lf.json +0 -0
  88. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-space.json +0 -0
  89. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-tab.json +0 -0
  90. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-vt.json +0 -0
  91. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-cr.json +0 -0
  92. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-ff.json +0 -0
  93. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-lf.json +0 -0
  94. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-space.json +0 -0
  95. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-tab.json +0 -0
  96. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-vt.json +0 -0
  97. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-job-and-urn-both-blank.json +0 -0
  98. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-reemit-job-ascii-padded.json +0 -0
  99. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-reemit-job-unicode-padded.json +0 -0
  100. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-reemit-trace-id-padded.json +0 -0
  101. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-unicode-job-emsp.json +0 -0
  102. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-unicode-job-ideographic.json +0 -0
  103. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-unicode-job-nbsp.json +0 -0
  104. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-urn-only-ascii-blank.json +0 -0
  105. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-urn-only-unicode-space.json +0 -0
  106. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  107. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
  108. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
  109. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
  110. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  111. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/urn-only-roundtrip.json +0 -0
  112. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/wrong-case-keys-roundtrip.json +0 -0
  113. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  114. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/schema/results.schema.json +0 -0
  115. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/fakes/confluent_kafka/__init__.py +0 -0
  116. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/fakes/confluent_kafka/admin.py +0 -0
  117. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/fixtures/dead-lettered.json +0 -0
  118. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/fixtures/order-created.json +0 -0
  119. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/redis_backend.py +0 -0
  120. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_artemis_conformance.py +0 -0
  121. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_artemis_transport.py +0 -0
  122. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_asb_conformance.py +0 -0
  123. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_asb_transport.py +0 -0
  124. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_celery.py +0 -0
  125. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_codec.py +0 -0
  126. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_dead_letter.py +0 -0
  127. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_django.py +0 -0
  128. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_gdpr.py +0 -0
  129. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_headers.py +0 -0
  130. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_idempotency.py +0 -0
  131. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_kafka_conformance.py +0 -0
  132. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_kafka_integration.py +0 -0
  133. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_kafka_integration_logic.py +0 -0
  134. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_kafka_transport.py +0 -0
  135. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_otel.py +0 -0
  136. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_outbox.py +0 -0
  137. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_overhead.py +0 -0
  138. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_pika_transport.py +0 -0
  139. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_pulsar_conformance.py +0 -0
  140. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_redrive.py +0 -0
  141. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_reencode_regression.py +0 -0
  142. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_replay.py +0 -0
  143. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_schema.py +0 -0
  144. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_schema_conformance.py +0 -0
  145. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_shutdown.py +0 -0
  146. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_sqs_conformance.py +0 -0
  147. {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_sqs_transport.py +0 -0
@@ -177,7 +177,7 @@ jobs:
177
177
  --health-retries 10
178
178
  env:
179
179
  # The pinned conformance release (ADR-0032). Bump together with tests/conformance/.
180
- BQ_CONFORMANCE_VERSION: 1.6.0
180
+ BQ_CONFORMANCE_VERSION: 1.7.0
181
181
  # sha256 of that release's fetch.sh: the script runs on the runner, so it is pinned rather
182
182
  # than trusted from a mutable tag. Bump together with BQ_CONFORMANCE_VERSION.
183
183
  BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849
@@ -14,7 +14,7 @@ jobs:
14
14
  # Recommended for PyPI Trusted Publishing — configure a "pypi" environment +
15
15
  # a trusted publisher on PyPI (repo BabelQueue/babelqueue-python, this workflow).
16
16
  environment: pypi
17
- # redis_layout (conformance 1.6.0) drives the real Lua scripts: a real redis:7 is mandatory,
17
+ # redis_layout (conformance 1.7.0) drives the real Lua scripts: a real redis:7 is mandatory,
18
18
  # exactly as in ci.yml's `conformance` job — without it the cases are reported as failures.
19
19
  services:
20
20
  redis:
@@ -28,7 +28,7 @@ jobs:
28
28
  --health-retries 10
29
29
  env:
30
30
  # The pinned conformance release (ADR-0032). Keep identical to ci.yml.
31
- BQ_CONFORMANCE_VERSION: 1.6.0
31
+ BQ_CONFORMANCE_VERSION: 1.7.0
32
32
  BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849
33
33
  permissions:
34
34
  id-token: write # PyPI Trusted Publishing (OIDC) — no API token needed
@@ -9,6 +9,76 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.23.0] - 2026-10-25
13
+
14
+ Implements ADR-0036 (post-R1 parity) decisions 2, 4, 5 and 6. A MINOR: every change is a
15
+ consumer-side settle decision, so the envelope stays at `schema_version: 1`. The vendored
16
+ conformance suite moves to v1.7.0 (new sections `identity_verdict` and `pulsar_exhaustion`, both
17
+ passing; `conformance-known-gaps.json` stays empty).
18
+
19
+ ### Changed (behaviour)
20
+ - **A body that does not decode, or has no identity, is poison on every transport (ADR-0036
21
+ decisions 4 and 6).** A JSON object that has neither a valid string `job` nor a valid string
22
+ `urn` (ADR-0032 section 6) used to be an *unknown URN*, and on SQS, AMQP, Pulsar, Azure Service
23
+ Bus, Artemis and the memory transport a body that did not decode was decoded leniently into an
24
+ empty envelope and retried as an unknown URN, finally dead-lettered as `reason: "unknown_urn"`
25
+ without the original body. Both are now **poison** and take the poison route of Go's
26
+ `routePoison`: they are never handed to a handler and never take the unknown-URN strategies
27
+ (those remain for a valid envelope whose URN has no handler). With the DLQ on (`dead_letter=True`
28
+ or `on_unknown_urn=DEAD_LETTER`) the message is written to the DLQ at once, with no retry, as the
29
+ ADR-0035 record (`dead_letter.reason: "poison"`, the original body preserved: a strict JSON
30
+ object spliced byte for byte, anything else as `raw`, base64 plus `raw_encoding` when it is not
31
+ valid UTF-8) and acknowledged after the write; `on_unknown_urn=DELETE` drops it (logged); with
32
+ the DLQ off `RELEASE` is replaced by the bounded `FAIL` path (re-published with `attempts + 1`
33
+ up to `max_attempts`, then dropped and logged). An operator who relied on a poison body being
34
+ retried through the unknown-URN strategy, or on a `reason: "unknown_urn"` record for it, sees the
35
+ new behaviour. The classification is `EnvelopeCodec.poison_cause(raw)` (new, pure): the typed
36
+ decode of the Go reference plus the identity rule; `decode` / `decode_lenient` are unchanged.
37
+ - **Pulsar poison records carry `reason: "poison"`** (they were `unknown_urn`). The Pulsar
38
+ transport also reports the reconciled `bq-attempts` / redelivery count to the runtime
39
+ (`ReceivedMessage.native_attempts`, new), as does SQS (`ApproximateReceiveCount - 1`), so a poison
40
+ body, which cannot carry `attempts`, is still bounded by `max_attempts`.
41
+ - **Redis: an identity-less body is dead-lettered unconditionally (ADR-0036 decision 4,
42
+ broker-bindings section 1.1 "Identity-less body").** In both layouts (legacy `:processing`
43
+ and Laravel-compatible zset) a body that decodes but names no identity (including a bare `null`,
44
+ which used to follow the bounded `FAIL` path) is handed over as transport poison: it is written
45
+ to `<queue>.dlq` as the ADR-0035 record with `reason: "poison"` in every configuration
46
+ (`on_unknown_urn=DELETE` and dead-lettering off change nothing), never handed to a handler and
47
+ never retried. Nothing else in the Redis transport changes.
48
+ - **Kafka without retry tiers: a poison record no longer blocks the partition (ADR-0036 decision
49
+ 5, broker-bindings section 6.5 degrade-to-delete).** With no `retry_tiers_ms`, dead-lettering off
50
+ and `on_unknown_urn` not `DELETE`, the bounded retry of a poison record (including an
51
+ identity-less body) needs a tier and raised, leaving the record uncommitted with `bq-attempts`
52
+ never advancing. It is now committed, dropped and logged, and the partition advances. A failing
53
+ handler and `RELEASE` of a valid envelope keep the section 6.5 rule (they still raise
54
+ `BabelQueueError` without tiers). The start-up warning for a tierless transport now says so; enable
55
+ `dead_letter` to keep such records in `<topic>.dlq`.
56
+ - A message dropped after its retries are exhausted with no DLQ configured is now logged
57
+ (`babelqueue.app`, WARNING) instead of being dropped silently.
58
+
59
+ - **Pulsar: a failed DLQ write or retry publish is negatively acknowledged (Go `Rewind`).**
60
+ `PulsarTransport` is now a `Rewinder` (`negative_acknowledge`), so the unsettled original is
61
+ redelivered by the broker instead of staying un-acknowledged for the life of the consumer. A
62
+ graceful-shutdown release of an in-flight Pulsar message is now a nack as well rather than a
63
+ re-publish (on a `Shared` subscription the native redelivery count advances; on `Exclusive` /
64
+ `Failover` it does not, which is harmless: `bq-attempts` carries the attempt limit).
65
+
66
+ ### Fixed
67
+ - **Bytes that are not valid UTF-8 no longer crash the AMQP, Pulsar and Artemis consumers.** They
68
+ were decoded strictly in `pop`; they are now kept losslessly (`surrogateescape`). Invalid UTF-8
69
+ is poison wherever it sits, **also inside a JSON string** (`EnvelopeCodec.poison_cause`,
70
+ error-handling section 7): it is never handed to a handler and is dead-lettered as the ADR-0035
71
+ base64 record, like on Redis and Kafka.
72
+
73
+ ### Added
74
+ - `EnvelopeCodec.poison_cause(raw, *, require_identity=True)`; `Transport.classifies_poison`
75
+ (Kafka and Redis classify poison themselves and opt out of the runtime's classification);
76
+ `ReceivedMessage.native_attempts`.
77
+ - Conformance runners for `identity_verdict` (the core verdict on all 97 bodies, plus the fate on
78
+ the memory transport) and `pulsar_exhaustion` (all 29 rows, driven through the real
79
+ `PulsarTransport` and runtime over a duck-typed client), and per-transport poison tests.
80
+ - `ci.yml` / `release.yml` pin conformance `1.7.0` (the `fetch.sh` SHA-256 is unchanged).
81
+
12
82
  ## [1.22.0] - 2026-10-24
13
83
 
14
84
  Implements ADR-0033 (Redis) and ADR-0034 (Kafka tiers, backoff, poison). The envelope stays at
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: babelqueue
3
- Version: 1.22.0
3
+ Version: 1.23.0
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
@@ -313,9 +313,31 @@ the DLQ on (`dead_letter` or `on_unknown_urn=DEAD_LETTER`) it is dead-lettered o
313
313
  written to `<topic>.dlq` (which must exist) and its offset is committed once that write
314
314
  is confirmed; a failed write leaves it uncommitted and rewound. With the DLQ off nothing is written:
315
315
  `on_unknown_urn=DELETE` drops it, any other strategy retries it through the retry tiers up to
316
- `max_attempts` and then drops it (without tiers the record is redelivered in place, as above). On
317
- the Redis legacy (`<queue>:processing`) layout a body that Go's typed decode rejects (neither a JSON
318
- object nor `null`, or wrong member types) is dead-lettered raw on its first
316
+ `max_attempts` and then drops it. **Without retry tiers** that bounded retry has no tier to use, so
317
+ (with the DLQ off and `on_unknown_urn` not `DELETE`) the poison record is committed, dropped and logged
318
+ instead of blocking the partition (section 6.5 degrade-to-delete, ADR-0036 decision 5); enable
319
+ `dead_letter` to keep it in `<topic>.dlq`. A failing handler without tiers still cannot be retried, as above.
320
+
321
+ ### Poison messages on every other transport
322
+
323
+ A message body that does not decode into an envelope (not a JSON object, malformed, a wrongly typed
324
+ member, or bytes that are not valid UTF-8, even inside a JSON string) or that has neither a valid
325
+ string `job` nor a valid string `urn` is **poison** on every transport (ADR-0036 decisions 2, 4 and
326
+ 6; conformance `identity_verdict`). It is never handed to a handler and never takes the
327
+ unknown-URN strategies, which are for a valid envelope whose URN has no handler:
328
+
329
+ - DLQ on (`dead_letter` or `on_unknown_urn=DEAD_LETTER`): written to `<queue>.dlq` at once, with no
330
+ retry, as the ADR-0035 record (`dead_letter.reason: "poison"`, the original body preserved,
331
+ base64 plus `raw_encoding` when it is not UTF-8), then acknowledged. A failed write leaves it
332
+ unacknowledged.
333
+ - `on_unknown_urn=DELETE`: acknowledged and dropped (logged).
334
+ - DLQ off: the bounded `FAIL` path (`RELEASE` is replaced by it): retried up to `max_attempts`, then
335
+ dropped and logged. The count comes from the broker where it has one (SQS receive count, Pulsar
336
+ `bq-attempts` / redelivery count), otherwise from a bare `{"attempts": n}` body the retry leaves.
337
+
338
+ On Redis (both layouts) an identity-less body is dead-lettered as poison in **every** configuration,
339
+ including `on_unknown_urn=DELETE` and dead-lettering off (broker-bindings section 1.1). On the legacy
340
+ (`<queue>:processing`) layout a body that Go's typed decode rejects is dead-lettered raw on its first
319
341
  delivery and removed from `:processing`.
320
342
 
321
343
  ### Sharing a Redis queue with Laravel
@@ -259,9 +259,31 @@ the DLQ on (`dead_letter` or `on_unknown_urn=DEAD_LETTER`) it is dead-lettered o
259
259
  written to `<topic>.dlq` (which must exist) and its offset is committed once that write
260
260
  is confirmed; a failed write leaves it uncommitted and rewound. With the DLQ off nothing is written:
261
261
  `on_unknown_urn=DELETE` drops it, any other strategy retries it through the retry tiers up to
262
- `max_attempts` and then drops it (without tiers the record is redelivered in place, as above). On
263
- the Redis legacy (`<queue>:processing`) layout a body that Go's typed decode rejects (neither a JSON
264
- object nor `null`, or wrong member types) is dead-lettered raw on its first
262
+ `max_attempts` and then drops it. **Without retry tiers** that bounded retry has no tier to use, so
263
+ (with the DLQ off and `on_unknown_urn` not `DELETE`) the poison record is committed, dropped and logged
264
+ instead of blocking the partition (section 6.5 degrade-to-delete, ADR-0036 decision 5); enable
265
+ `dead_letter` to keep it in `<topic>.dlq`. A failing handler without tiers still cannot be retried, as above.
266
+
267
+ ### Poison messages on every other transport
268
+
269
+ A message body that does not decode into an envelope (not a JSON object, malformed, a wrongly typed
270
+ member, or bytes that are not valid UTF-8, even inside a JSON string) or that has neither a valid
271
+ string `job` nor a valid string `urn` is **poison** on every transport (ADR-0036 decisions 2, 4 and
272
+ 6; conformance `identity_verdict`). It is never handed to a handler and never takes the
273
+ unknown-URN strategies, which are for a valid envelope whose URN has no handler:
274
+
275
+ - DLQ on (`dead_letter` or `on_unknown_urn=DEAD_LETTER`): written to `<queue>.dlq` at once, with no
276
+ retry, as the ADR-0035 record (`dead_letter.reason: "poison"`, the original body preserved,
277
+ base64 plus `raw_encoding` when it is not UTF-8), then acknowledged. A failed write leaves it
278
+ unacknowledged.
279
+ - `on_unknown_urn=DELETE`: acknowledged and dropped (logged).
280
+ - DLQ off: the bounded `FAIL` path (`RELEASE` is replaced by it): retried up to `max_attempts`, then
281
+ dropped and logged. The count comes from the broker where it has one (SQS receive count, Pulsar
282
+ `bq-attempts` / redelivery count), otherwise from a bare `{"attempts": n}` body the retry leaves.
283
+
284
+ On Redis (both layouts) an identity-less body is dead-lettered as poison in **every** configuration,
285
+ including `on_unknown_urn=DELETE` and dead-lettering off (broker-bindings section 1.1). On the legacy
286
+ (`<queue>:processing`) layout a body that Go's typed decode rejects is dead-lettered raw on its first
265
287
  delivery and removed from `:processing`.
266
288
 
267
289
  ### Sharing a Redis queue with Laravel
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "1.22.0"
7
+ version = "1.23.0"
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"
@@ -42,7 +42,7 @@ from .transport import (
42
42
  Transport,
43
43
  )
44
44
 
45
- __version__ = "1.22.0"
45
+ __version__ = "1.23.0"
46
46
 
47
47
  __all__ = [
48
48
  "BabelQueue",
@@ -15,7 +15,7 @@ Go reference's ``babelqueue.Decode``, except that member names are case-sensitiv
15
15
  decoder read them) and every occurrence of a duplicated key is checked; any other member (a
16
16
  differently-cased name or a non-string ``urn`` alias included) is only checked for syntax.
17
17
  A non-string ``job`` is **not** poison either: the identity resolver ignores it (ADR-0032 section 6),
18
- so the body is readable and simply carries no identity (conformance ``kafka_poison``, v1.6.0).
18
+ so the body is readable and simply carries no identity (conformance ``kafka_poison``, v1.6.0, and ``identity_verdict``, v1.7.0).
19
19
 
20
20
  A well-typed but non-conformant envelope is not poison: Go's consume path never calls ``Accepts``.
21
21
  """
@@ -326,15 +326,37 @@ class BabelQueue:
326
326
  # §3.7/§4.7/§5.7/§6.7/§7.7): the raw body goes to the DLQ byte-identical, so there is
327
327
  # no envelope to read here.
328
328
  gated = received.route_urn.startswith(_UNSUPPORTED_SCHEMA_ROUTE_PREFIX)
329
- envelope = {} if gated else EnvelopeCodec.decode_lenient(received.body)
329
+ # A body that does not decode, or has no identity, is poison (ADR-0036 decisions 4 and
330
+ # 6): never decoded leniently into an empty envelope, never handed to a handler and
331
+ # never routed as an unknown URN. A transport that already hands its poison over under
332
+ # a poison route (Kafka, Redis) is not classified twice. A delivery that carries an
333
+ # out-of-band routing URN is exempt from the identity half of the rule only.
334
+ poison_cause = (
335
+ None
336
+ if gated or getattr(self.transport, "classifies_poison", False)
337
+ else EnvelopeCodec.poison_cause(
338
+ received.body, require_identity=not received.route_urn
339
+ )
340
+ )
341
+ envelope = (
342
+ {}
343
+ if gated or poison_cause is not None
344
+ else EnvelopeCodec.decode_lenient(received.body)
345
+ )
330
346
  urn = str(envelope.get("job") or envelope.get("urn") or "")
331
347
  if received.route_urn and envelope:
332
348
  # Transport-supplied routing override; body untouched. Only for a body that
333
349
  # decodes: an undecodable one (decode() yields {}) stays on the poison path.
334
350
  urn = received.route_urn
335
- handler = self._handlers.get(urn) if urn else None
351
+ handler = (
352
+ self._handlers.get(urn) if urn and poison_cause is None else None
353
+ )
336
354
 
337
355
  try:
356
+ if poison_cause is not None:
357
+ self._phase = _SETTLING
358
+ self._route_body_poison(received, poison_cause)
359
+ return
338
360
  if handler is None:
339
361
  self._phase = _SETTLING
340
362
  self._route_unknown(urn, received, envelope)
@@ -344,15 +366,19 @@ class BabelQueue:
344
366
  self._phase = _SETTLING
345
367
  except Exception as exc: # noqa: BLE001 - one bad message must not kill the loop
346
368
  self._phase = _SETTLING
347
- if received.route_urn.startswith(_UNSUPPORTED_SCHEMA_ROUTE_PREFIX):
348
- # A version-gated delivery never takes the generic retry path, even when its
349
- # dead-letter write fails: a retry re-publish may rebuild the version header
350
- # from the (valid) body and walk the message past the gate into a handler.
351
- # It is left unacked; the broker (or a rewind, for cumulative-offset brokers
352
- # such as Kafka) brings it back and the gate rejects it again.
369
+ if poison_cause is not None or received.route_urn.startswith(
370
+ _UNSUPPORTED_SCHEMA_ROUTE_PREFIX
371
+ ):
372
+ # A version-gated or poison delivery never takes the generic retry path, even
373
+ # when its dead-letter write fails: a retry re-publish may rebuild the version
374
+ # header from the (valid) body and walk the message past the gate into a
375
+ # handler, and a poison body has no envelope to retry. It is left unacked; the
376
+ # broker (or a rewind, for cumulative-offset brokers such as Kafka) brings it
377
+ # back and the same decision is taken again.
353
378
  logger.error(
354
- "Dead-lettering a version-gated message (route %r) on queue %r failed; it was NOT "
355
- "retried or acknowledged; it will come back and the dead-letter write will be retried.",
379
+ "Dead-lettering a version-gated or poison message (route %r) on queue %r "
380
+ "failed; it was NOT retried or acknowledged; it will come back and the "
381
+ "dead-letter write will be retried.",
356
382
  received.route_urn,
357
383
  received.queue,
358
384
  exc_info=exc,
@@ -579,13 +605,16 @@ class BabelQueue:
579
605
  through a tier. That only happens once a handler fails, so it is a warning (not a
580
606
  start-up error).
581
607
  """
582
- tiers = getattr(self.transport, "retry_tiers_ms", None)
583
- if isinstance(tiers, list) and not tiers and self.max_attempts > 1 and isinstance(self.transport, Redeliverer):
608
+ if self._retry_tiers_unavailable() and self.max_attempts > 1:
584
609
  logger.warning(
585
610
  "The Kafka transport has no retry tiers but max_attempts=%d: a failing handler "
586
611
  "cannot be retried -- the record is left uncommitted and redelivered after a "
587
612
  "pause, so it keeps failing in place. Configure retry_tiers_ms (or set "
588
- "max_attempts=1) to retry through the tiers.",
613
+ "max_attempts=1) to retry through the tiers. A poison record (a body that does "
614
+ "not decode, or has no identity) is different: with dead-lettering off and "
615
+ "on_unknown_urn not 'delete' it is committed and dropped with a logged warning "
616
+ "(broker-bindings section 6.5, degrade-to-delete), so it never blocks the "
617
+ "partition; enable dead_letter to keep it in <topic>.dlq instead.",
589
618
  self.max_attempts,
590
619
  )
591
620
 
@@ -657,6 +686,19 @@ class BabelQueue:
657
686
  if self.dead_letter_enabled or self.on_unknown_urn == UnknownUrnStrategy.DEAD_LETTER:
658
687
  self._settle_version_gated(received, always=True)
659
688
  return
689
+ if self._retry_tiers_unavailable():
690
+ # broker-bindings §6.5 / ADR-0036 decision 5: no retry tier, no dead-lettering and not
691
+ # ``delete`` degrades to ``delete``. The bounded retry of a poison record needs a tier
692
+ # and would raise, leaving the record uncommitted with ``bq-attempts`` never advancing,
693
+ # so the partition would block. Commit, drop, and say so.
694
+ logger.warning(
695
+ "Dropping a poison record on queue %r (route %r): the Kafka transport has no retry "
696
+ "tiers and dead-lettering is off, so it can be neither retried nor dead-lettered "
697
+ "(broker-bindings section 6.5, degrade-to-delete). The offset was committed.",
698
+ received.queue, str(route),
699
+ )
700
+ self._ack_settled(received)
701
+ return
660
702
  attempts = int(getattr(route, "attempts", 0) or 0)
661
703
  self._retry_or_dead_letter(
662
704
  received,
@@ -664,8 +706,60 @@ class BabelQueue:
664
706
  UnknownUrnError("The record is poison: it carries no decodable envelope."),
665
707
  )
666
708
 
667
- def _settle_transport_poison(self, received: ReceivedMessage) -> None:
668
- """Dead-letter a Redis poison reservation (broker-bindings §1.1 / §1.3, ADR-0033, ADR-0035).
709
+ def _route_body_poison(self, received: ReceivedMessage, cause: str) -> None:
710
+ """Settle a body that does not decode into an envelope, or has no identity, on SQS, AMQP,
711
+ Pulsar, Azure Service Bus, Artemis, the memory transport and any other transport that does
712
+ not classify poison itself (ADR-0036 decisions 2, 4 and 6; Go ``routePoison``).
713
+
714
+ The body is never handed to a handler and never takes the unknown-URN strategies:
715
+
716
+ * ``on_unknown_urn=DELETE`` drops it (acknowledged and logged);
717
+ * with the DLQ on (``dead_letter`` or the DEAD_LETTER strategy) it is written to the DLQ as
718
+ the ADR-0035 record with ``reason: "poison"`` (original body preserved) and acknowledged
719
+ after the write, with no retry;
720
+ * with the DLQ off it takes the bounded FAIL path (``RELEASE`` is replaced by it, a poison
721
+ body would otherwise be released forever): re-published with ``attempts + 1`` up to
722
+ ``max_attempts``, then dropped. The count is the larger of the transport's own delivery
723
+ count and the ``attempts`` of the bare body a previous retry left behind.
724
+
725
+ A failed DLQ write propagates: :meth:`dispatch` leaves the delivery unacknowledged."""
726
+ if self.on_unknown_urn == UnknownUrnStrategy.DELETE:
727
+ logger.warning(
728
+ "Dropping a poison message on queue %r (on_unknown_urn=delete): %s.",
729
+ received.queue, cause,
730
+ )
731
+ self._ack_settled(received)
732
+ return
733
+ attempts = self._poison_attempts(received)
734
+ if self.dead_letter_enabled or self.on_unknown_urn == UnknownUrnStrategy.DEAD_LETTER:
735
+ self._settle_transport_poison(received, cause, attempts)
736
+ return
737
+ self._retry_or_dead_letter(
738
+ received,
739
+ {"attempts": attempts},
740
+ UnknownUrnError("The message is poison: " + cause),
741
+ )
742
+
743
+ @staticmethod
744
+ def _poison_attempts(received: ReceivedMessage) -> int:
745
+ """Deliveries a poison body has already had: the larger of the transport's own count and
746
+ the integer ``attempts`` of the body (a bare ``{"attempts": N}`` left by a retry)."""
747
+ body_attempts = parse_envelope(received.body).get("attempts")
748
+ if isinstance(body_attempts, bool) or not isinstance(body_attempts, int):
749
+ body_attempts = 0
750
+ return max(int(received.native_attempts or 0), body_attempts, 0)
751
+
752
+ def _retry_tiers_unavailable(self) -> bool:
753
+ """Whether the transport is a Kafka transport with no retry tiers configured."""
754
+ tiers = getattr(self.transport, "retry_tiers_ms", None)
755
+ return isinstance(tiers, list) and not tiers and isinstance(self.transport, Redeliverer)
756
+
757
+ def _settle_transport_poison(
758
+ self, received: ReceivedMessage, cause: Optional[str] = None, attempts: int = 0
759
+ ) -> None:
760
+ """Dead-letter a poison delivery (broker-bindings §1.1 / §1.3, ADR-0033, ADR-0035): a Redis
761
+ poison reservation (unconditional) or, via :meth:`_route_body_poison`, a poison body of any
762
+ other transport (``cause`` and ``attempts`` are then known).
669
763
 
670
764
  The DLQ write is an unconditional MUST, so it ignores ``dead_letter_enabled``. The record
671
765
  follows ADR-0035 like every other poison record: a strict JSON object body is spliced byte
@@ -676,8 +770,8 @@ class BabelQueue:
676
770
  {},
677
771
  "poison",
678
772
  received.queue,
679
- 0,
680
- error="Undecodable BabelQueue body",
773
+ attempts,
774
+ error="Undecodable BabelQueue body" + (": " + cause if cause else ""),
681
775
  exception=BabelQueueError.__name__,
682
776
  )["dead_letter"]
683
777
  self._write_dead_letter(
@@ -730,7 +824,12 @@ class BabelQueue:
730
824
  self._dead_letter(received, envelope, reason, exc)
731
825
  return
732
826
 
733
- # Retries exhausted, no DLQ configured — drop it (ack so it leaves the queue).
827
+ # Retries exhausted, no DLQ configured — drop it (ack so it leaves the queue), loudly.
828
+ logger.warning(
829
+ "Dropping a message on queue %r after %d attempts: retries are exhausted and no "
830
+ "dead-letter queue is configured.",
831
+ received.queue, attempts,
832
+ )
734
833
  self._ack_settled(received)
735
834
 
736
835
  def _dead_letter(
@@ -233,7 +233,8 @@ class ArtemisTransport(Transport):
233
233
  def _payload(message: Any) -> str:
234
234
  body = getattr(message, "body", None)
235
235
  if isinstance(body, bytes):
236
- return body.decode("utf-8")
236
+ # Lossless: see ``PulsarTransport._payload`` (ADR-0035 poison record, no crash).
237
+ return body.decode("utf-8", "surrogateescape")
237
238
  return str(body) if body is not None else ""
238
239
 
239
240
  # -- Transport ----------------------------------------------------------
@@ -16,7 +16,7 @@ import time
16
16
  import uuid
17
17
  from typing import Any, Dict, List, Mapping, Optional, Tuple
18
18
 
19
- from ._envelope_shape import CORE_META_KEYS
19
+ from ._envelope_shape import CORE_META_KEYS, envelope_violation
20
20
  from .exceptions import BabelQueueError, EnvelopeKeyError
21
21
 
22
22
  SCHEMA_VERSION = 1
@@ -89,6 +89,15 @@ def has_forbidden_keys(envelope: Mapping[str, Any]) -> bool:
89
89
  return _has_forbidden_keys(envelope)
90
90
 
91
91
 
92
+ def has_invalid_utf8(text: str) -> bool:
93
+ """Whether ``text`` carries a byte that was not valid UTF-8 when the transport read it.
94
+
95
+ Transports read a body losslessly (``errors="surrogateescape"``), so every invalid byte
96
+ appears as a lone surrogate in ``U+DC80..U+DCFF``. A ``\\udcff`` *escape* in the JSON text is
97
+ plain ASCII and is not flagged."""
98
+ return any("\udc80" <= ch <= "\udcff" for ch in text)
99
+
100
+
92
101
  def _is_valid_urn(value: Any) -> bool:
93
102
  """A usable identity: a string that is not blank under the ASCII set (ADR-0032 §5/§6).
94
103
 
@@ -299,6 +308,42 @@ class EnvelopeCodec:
299
308
  parsed = _parse_object(raw)
300
309
  return {} if parsed is None else _fold_identity(parsed)
301
310
 
311
+ @staticmethod
312
+ def poison_cause(raw: str, *, require_identity: bool = True) -> Optional[str]:
313
+ """Why ``raw`` is a **poison** body, or ``None`` when it decodes into an envelope with an
314
+ identity (ADR-0036 decision 4, conformance ``identity_verdict``).
315
+
316
+ A body is poison when it is not exactly one JSON object (malformed, BOM, trailing tokens,
317
+ ``NaN`` / ``Infinity``, a bare ``null``, an array, a scalar), when a known member has the
318
+ wrong type (the trigger set of :func:`~babelqueue._envelope_shape.envelope_violation`, the
319
+ Go reference's typed ``Decode``), when the core parser cannot read it, or, with
320
+ ``require_identity``, when it has neither a valid string ``job`` nor a valid string
321
+ ``urn`` (ADR-0032 section 6: a ``job`` that is not a string is ignored, a blank one falls
322
+ back to ``urn``, a ``urn`` that is not a non-blank string is ignored; the last value of a
323
+ repeated member wins). A poison body is never handed to a handler and never takes the
324
+ unknown-URN strategies, which are for a valid envelope whose URN has no handler.
325
+
326
+ ``require_identity=False`` keeps only the structural checks, for a delivery that a
327
+ transport routes on an out-of-band identity property (the Pulsar ``bq-job``), which is
328
+ outside the identity rule. Pure: no warning is logged and nothing is mutated."""
329
+ if has_invalid_utf8(raw):
330
+ # error-handling.md section 7: a body that is not valid UTF-8 is poison even when its
331
+ # invalid bytes sit inside a JSON string, where ``json.loads`` would accept the lone
332
+ # surrogate they were escaped to. It must never reach a handler or be re-encoded.
333
+ return "the body is not valid UTF-8"
334
+ try:
335
+ violation = envelope_violation(raw)
336
+ if violation is not None:
337
+ return violation
338
+ parsed = json.loads(raw)
339
+ except (ValueError, RecursionError, TypeError) as exc:
340
+ return "the core decoder cannot parse the body: " + str(exc)
341
+ if not isinstance(parsed, dict):
342
+ return "the JSON root is not an object"
343
+ if require_identity and not (_is_valid_urn(parsed.get("job")) or _is_valid_urn(parsed.get("urn"))):
344
+ return "the body carries no valid 'job' or 'urn' identity (ADR-0032 section 6)"
345
+ return None
346
+
302
347
  @staticmethod
303
348
  def urn(envelope: Mapping[str, Any]) -> str:
304
349
  """The message URN: canonical ``job``, with ``urn`` accepted as an alias.
@@ -28,8 +28,10 @@ spliced byte for byte, anything else is carried as ``{"raw": "<body>"}`` (base64
28
28
  ``raw_encoding: "base64"`` when it is not valid UTF-8), and a ``dead_letter`` block with
29
29
  ``reason: "poison"`` is added (the key is preserved; the DLQ is the configured one or ``<queue>.dlq``, which must exist). With
30
30
  the DLQ off it is NOT written to a DLQ (Go ``routePoison``): ``on_unknown_urn=DELETE`` drops it, any other
31
- strategy takes the bounded FAIL path (retried through the tiers up to ``max_attempts``, then dropped).
32
- The record is handed over raw under a
31
+ strategy takes the bounded FAIL path (retried through the tiers up to ``max_attempts``, then dropped); with
32
+ no retry tiers configured as well, that path has no tier to retry through, so the record is committed, dropped
33
+ and logged (the §6.5 degrade-to-delete rule, ADR-0036 decision 5) instead of blocking the partition. The record is
34
+ handed over raw under a
33
35
  ``PoisonRoute`` and wrapped by ``publish_settle`` only when it is written to a queue other than the one
34
36
  it was read from, so a ``redrive`` restore into the DLQ it popped it from (any DLQ name) is byte for
35
37
  byte and never double-wraps. The version gate (§6.7) still
@@ -161,6 +163,10 @@ def _brokers_from_url(url: str) -> str:
161
163
 
162
164
 
163
165
  class KafkaTransport(Transport):
166
+ #: Classifies poison itself (``_poll_one``, ADR-0034 section 10): the runtime does not classify
167
+ #: the body a second time.
168
+ classifies_poison = True
169
+
164
170
  def __init__(
165
171
  self,
166
172
  url: str = "kafka://localhost:9092",
@@ -115,7 +115,9 @@ class PikaTransport(Transport):
115
115
  if timeout and timeout > 0:
116
116
  self._connection.sleep(timeout)
117
117
  return None
118
- text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) else str(body)
118
+ # Lossless: bytes that are not UTF-8 stay recoverable (``surrogateescape``) so a poison
119
+ # body is dead-lettered as the ADR-0035 base64 record instead of crashing the consumer.
120
+ text = body.decode("utf-8", "surrogateescape") if isinstance(body, (bytes, bytearray)) else str(body)
119
121
  headers = _delivery_headers(props)
120
122
  return ReceivedMessage(body=text, queue=queue, handle=method.delivery_tag, headers=headers)
121
123
 
@@ -230,6 +230,17 @@ class PulsarTransport(Transport):
230
230
  value = int(raw)
231
231
  return value if value <= _MAX_ATTEMPTS else None
232
232
 
233
+ @staticmethod
234
+ def _native_attempts(redelivery_count: Any, properties: Optional[Dict[str, str]] = None) -> int:
235
+ """``max(bq-attempts, RedeliveryCount)`` (§5.5; 0-based): the reconciled count of a delivery
236
+ that has no decodable body attempts. An invalid or absent ``bq-attempts`` counts as 0."""
237
+ from_property = PulsarTransport._parse_attempts((properties or {}).get("bq-attempts")) or 0
238
+ try:
239
+ native = int(redelivery_count)
240
+ except (TypeError, ValueError):
241
+ native = 0
242
+ return max(from_property, native, 0)
243
+
233
244
  @staticmethod
234
245
  def _reconcile(
235
246
  body: str,
@@ -320,7 +331,9 @@ class PulsarTransport(Transport):
320
331
  getter = getattr(message, "data", None)
321
332
  data = getter() if callable(getter) else getter
322
333
  if isinstance(data, bytes):
323
- return data.decode("utf-8")
334
+ # Lossless: bytes that are not UTF-8 stay recoverable (``surrogateescape``) so a poison
335
+ # body is dead-lettered as the ADR-0035 base64 record instead of crashing the consumer.
336
+ return data.decode("utf-8", "surrogateescape")
324
337
  return str(data) if data is not None else ""
325
338
 
326
339
  # -- Transport ----------------------------------------------------------
@@ -370,13 +383,33 @@ class PulsarTransport(Transport):
370
383
  properties,
371
384
  self._on_routing_mismatch,
372
385
  )
373
- return ReceivedMessage(body=body, queue=queue, handle=message, route_urn=route_urn)
386
+ return ReceivedMessage(
387
+ body=body,
388
+ queue=queue,
389
+ handle=message,
390
+ route_urn=route_urn,
391
+ # The §5.5 reconciled count without a body: what a poison body (no envelope to carry
392
+ # ``attempts``) is counted by (ADR-0036 decision 2).
393
+ native_attempts=self._native_attempts(self._redelivery_count(message), properties),
394
+ )
374
395
 
375
396
  def ack(self, message: ReceivedMessage) -> None:
376
397
  if message.handle is None:
377
398
  return
378
399
  self._consumer(message.queue).acknowledge(message.handle)
379
400
 
401
+ def rewind(self, message: ReceivedMessage) -> None:
402
+ """:class:`~babelqueue.transport.Rewinder`: negatively acknowledge ``message`` so the broker
403
+ redelivers it (Go ``Rewind`` = ``Nack``; ADR-0036 decision 1: a failed DLQ write or retry
404
+ publish leaves the original un-acknowledged for redelivery instead of leaving it unsettled
405
+ for the life of the consumer). On a ``Shared`` subscription the native redelivery count
406
+ advances, which the §5.5 reconciliation reads back; on ``Exclusive`` / ``Failover`` it does
407
+ not, which is harmless: the attempt limit is carried by the ``bq-attempts`` property and
408
+ the body, never by the nack."""
409
+ if message.handle is None:
410
+ return
411
+ self._consumer(message.queue).negative_acknowledge(message.handle)
412
+
380
413
  def close(self) -> None: # pragma: no cover - resource cleanup
381
414
  for resource in (*self._producers.values(), *self._consumers.values()):
382
415
  try: