babelqueue 1.23.0__tar.gz → 1.24.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 (149) hide show
  1. {babelqueue-1.23.0 → babelqueue-1.24.0}/.github/workflows/ci.yml +1 -1
  2. {babelqueue-1.23.0 → babelqueue-1.24.0}/.github/workflows/release.yml +2 -2
  3. {babelqueue-1.23.0 → babelqueue-1.24.0}/CHANGELOG.md +65 -0
  4. {babelqueue-1.23.0 → babelqueue-1.24.0}/PKG-INFO +16 -4
  5. {babelqueue-1.23.0 → babelqueue-1.24.0}/README.md +15 -3
  6. {babelqueue-1.23.0 → babelqueue-1.24.0}/pyproject.toml +1 -1
  7. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/__init__.py +7 -1
  8. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/app.py +88 -10
  9. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/asb_transport.py +37 -0
  10. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/sqs_transport.py +29 -0
  11. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/transport.py +53 -0
  12. babelqueue-1.24.0/tests/conformance/CONFORMANCE_VERSION +1 -0
  13. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/manifest.json +705 -1
  14. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/schema/results.example.json +1 -1
  15. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_app.py +114 -0
  16. babelqueue-1.24.0/tests/test_asb_conformance.py +177 -0
  17. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_asb_transport.py +148 -1
  18. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_behaviour_conformance.py +108 -4
  19. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_conformance.py +4 -0
  20. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_poison_routing.py +85 -15
  21. babelqueue-1.24.0/tests/test_sqs_conformance.py +155 -0
  22. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_sqs_transport.py +99 -13
  23. babelqueue-1.23.0/tests/conformance/CONFORMANCE_VERSION +0 -1
  24. babelqueue-1.23.0/tests/test_asb_conformance.py +0 -84
  25. babelqueue-1.23.0/tests/test_sqs_conformance.py +0 -80
  26. {babelqueue-1.23.0 → babelqueue-1.24.0}/.github/FUNDING.yml +0 -0
  27. {babelqueue-1.23.0 → babelqueue-1.24.0}/.github/dependabot.yml +0 -0
  28. {babelqueue-1.23.0 → babelqueue-1.24.0}/.gitignore +0 -0
  29. {babelqueue-1.23.0 → babelqueue-1.24.0}/LICENSE +0 -0
  30. {babelqueue-1.23.0 → babelqueue-1.24.0}/conformance-known-gaps.json +0 -0
  31. {babelqueue-1.23.0 → babelqueue-1.24.0}/scripts/check_conformance_results.py +0 -0
  32. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/_envelope_shape.py +0 -0
  33. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/_poison_record.py +0 -0
  34. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/artemis_transport.py +0 -0
  35. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/celery.py +0 -0
  36. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/codec.py +0 -0
  37. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/contracts.py +0 -0
  38. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/dead_letter.py +0 -0
  39. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/django/__init__.py +0 -0
  40. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/django/apps.py +0 -0
  41. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/django/management/__init__.py +0 -0
  42. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  43. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  44. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/exceptions.py +0 -0
  45. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/gdpr.py +0 -0
  46. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/headers.py +0 -0
  47. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/idempotency.py +0 -0
  48. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/kafka_retry.py +0 -0
  49. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/kafka_transport.py +0 -0
  50. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/otel.py +0 -0
  51. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/outbox.py +0 -0
  52. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/pika_transport.py +0 -0
  53. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/pulsar_transport.py +0 -0
  54. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/py.typed +0 -0
  55. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/redis_transport.py +0 -0
  56. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/redrive.py +0 -0
  57. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/replay.py +0 -0
  58. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/routing.py +0 -0
  59. {babelqueue-1.23.0 → babelqueue-1.24.0}/src/babelqueue/schema.py +0 -0
  60. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/blank-ascii-job-urn-fallback.json +0 -0
  61. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
  62. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/dead-letter-roundtrip.json +0 -0
  63. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  64. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/duplicate-keys-last-wins.json +0 -0
  65. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
  66. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/forbidden-key-job-and-urn.json +0 -0
  67. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/forbidden-key-job-nonascii-space-and-urn.json +0 -0
  68. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
  69. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
  70. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
  71. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
  72. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
  73. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  74. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  75. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/invalid-wrong-case-job.json +0 -0
  76. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/job-type-array-urn-fallback.json +0 -0
  77. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/job-type-bool-urn-fallback.json +0 -0
  78. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/job-type-empty-string-urn-fallback.json +0 -0
  79. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/job-type-missing-job-array-urn.json +0 -0
  80. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/job-type-null-no-urn.json +0 -0
  81. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/job-type-null-urn-fallback.json +0 -0
  82. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/job-type-number-blank-urn.json +0 -0
  83. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/job-type-number-nonstring-urn.json +0 -0
  84. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/job-type-number-urn-fallback.json +0 -0
  85. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/job-type-object-urn-fallback.json +0 -0
  86. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/meta-ext-vendor-roundtrip.json +0 -0
  87. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/order-created.json +0 -0
  88. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-blank-job-cr.json +0 -0
  89. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-blank-job-ff.json +0 -0
  90. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-blank-job-lf.json +0 -0
  91. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-blank-job-space.json +0 -0
  92. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-blank-job-tab.json +0 -0
  93. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-blank-job-vt.json +0 -0
  94. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-blank-trace-id-cr.json +0 -0
  95. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-blank-trace-id-ff.json +0 -0
  96. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-blank-trace-id-lf.json +0 -0
  97. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-blank-trace-id-space.json +0 -0
  98. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-blank-trace-id-tab.json +0 -0
  99. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-blank-trace-id-vt.json +0 -0
  100. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-job-and-urn-both-blank.json +0 -0
  101. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-reemit-job-ascii-padded.json +0 -0
  102. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-reemit-job-unicode-padded.json +0 -0
  103. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-reemit-trace-id-padded.json +0 -0
  104. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-unicode-job-emsp.json +0 -0
  105. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-unicode-job-ideographic.json +0 -0
  106. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-unicode-job-nbsp.json +0 -0
  107. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-urn-only-ascii-blank.json +0 -0
  108. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/trim-urn-only-unicode-space.json +0 -0
  109. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  110. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
  111. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
  112. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
  113. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  114. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/urn-only-roundtrip.json +0 -0
  115. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/fixtures/wrong-case-keys-roundtrip.json +0 -0
  116. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  117. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/conformance/schema/results.schema.json +0 -0
  118. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/fakes/confluent_kafka/__init__.py +0 -0
  119. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/fakes/confluent_kafka/admin.py +0 -0
  120. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/fixtures/dead-lettered.json +0 -0
  121. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/fixtures/order-created.json +0 -0
  122. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/redis_backend.py +0 -0
  123. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_artemis_conformance.py +0 -0
  124. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_artemis_transport.py +0 -0
  125. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_celery.py +0 -0
  126. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_codec.py +0 -0
  127. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_dead_letter.py +0 -0
  128. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_django.py +0 -0
  129. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_gdpr.py +0 -0
  130. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_headers.py +0 -0
  131. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_idempotency.py +0 -0
  132. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_kafka_conformance.py +0 -0
  133. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_kafka_integration.py +0 -0
  134. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_kafka_integration_logic.py +0 -0
  135. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_kafka_retry.py +0 -0
  136. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_kafka_transport.py +0 -0
  137. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_otel.py +0 -0
  138. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_outbox.py +0 -0
  139. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_overhead.py +0 -0
  140. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_pika_transport.py +0 -0
  141. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_pulsar_conformance.py +0 -0
  142. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_pulsar_transport.py +0 -0
  143. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_redis_transport.py +0 -0
  144. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_redrive.py +0 -0
  145. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_reencode_regression.py +0 -0
  146. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_replay.py +0 -0
  147. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_schema.py +0 -0
  148. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_schema_conformance.py +0 -0
  149. {babelqueue-1.23.0 → babelqueue-1.24.0}/tests/test_shutdown.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.7.0
180
+ BQ_CONFORMANCE_VERSION: 1.8.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.7.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.7.0
31
+ BQ_CONFORMANCE_VERSION: 1.8.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,71 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.24.0] - 2026-10-26
13
+
14
+ Implements conformance **1.8.0** (vendored from the `v1.8.0` release asset, verified with
15
+ `fetch.sh --check`; `BQ_CONFORMANCE_VERSION` is now `1.8.0` in `ci.yml` and `release.yml`). The
16
+ envelope stays at `schema_version: 1`; nothing here touches the wire (ADR-0037). The new
17
+ `version_gate_pulsar`, `version_gate_artemis`, `version_gate_sqs` and `version_gate_asb` sections run
18
+ and pass (17 cases each; Python has the `on_unknown_urn` option, so no row is skipped). A MINOR.
19
+
20
+ ### Changed (behaviour)
21
+ - **With the DLQ off, SQS and Azure Service Bus no longer drop a version-gated message or retry a
22
+ poison body; they are left to the broker's native mechanism (ADR-0037 decision 1 rule 3 and
23
+ decision 2 "Go and Python"; broker-bindings 3.5, 4.5, 5.7; owner decision 2026-10-25).**
24
+ - *Version-gated message* (an unsupported `bq-schema-version`), `dead_letter` off and a strategy
25
+ other than `DELETE` or `DEAD_LETTER`: it used to be acknowledged and dropped. It is now **not**
26
+ acknowledged. SQS: no `DeleteMessage`; visibility expiry redelivers it (the gate rejects it
27
+ again) and the queue's `RedrivePolicy` moves it to the native DLQ after `maxReceiveCount`. Azure
28
+ Service Bus: an explicit native `DeadLetter` (reason `unsupported schema version`) into
29
+ `$DeadLetterQueue` instead of `Complete`.
30
+ - *Poison body* (invalid JSON or an identity-less object), `dead_letter` off and a strategy other
31
+ than `DELETE` or `DEAD_LETTER`: it used to take the bounded `FAIL` path (re-publish an empty
32
+ envelope up to `max_attempts`, then drop). On SQS and Azure Service Bus it is now settled
33
+ natively **on the first delivery** exactly as above (Azure Service Bus reason `poison`): no
34
+ re-publish, no retry, no ack, body unchanged, the event is logged. Every other transport
35
+ (Pulsar, Artemis, AMQP, Kafka, Redis, memory) keeps its behaviour; `DELETE` (ack and drop),
36
+ `dead_letter=True` and the `DEAD_LETTER` strategy (the raw record, then ack) are unchanged on
37
+ SQS and Azure Service Bus too.
38
+ - A deployment that relied on SQS/Azure Service Bus silently dropping these messages now needs a
39
+ `RedrivePolicy` (SQS) or accepts them in `$DeadLetterQueue` (Azure Service Bus), or sets
40
+ `dead_letter=True` / `on_unknown_urn=DELETE`.
41
+
42
+ ### Fixed
43
+ - **SQS: the default dead-letter queue name is now `<queue>-dlq` (FIFO `<queue>-dlq.fifo`) (ADR-0037
44
+ decision 7).** The old default `<queue>.dlq` was never a valid Amazon SQS queue name (a name may hold only
45
+ alphanumerics, hyphens and underscores, plus one `.fifo` suffix), so a DLQ write by default could not find a
46
+ queue on real SQS. `dead_letter_suffix` now defaults to `None` ("not set"); unless `dead_letter_queue` is set
47
+ the runtime asks the transport's new optional `DeadLetterNamer` capability
48
+ (`dead_letter_queue_name(queue, suffix)`, exported from `babelqueue`), which `SqsTransport` implements. The
49
+ runtime also hands it an explicit `dead_letter_suffix`; on a FIFO queue the `.fifo` suffix always stays last,
50
+ an explicit suffix included: `orders.fifo` with `dead_letter_suffix="-x"` is `orders-x.fifo`, never
51
+ `orders.fifo-x` (an SQS FIFO name must end in `.fifo`; before, that explicit suffix produced the uncreatable
52
+ `orders.fifo-x`). An explicit queue name behaves as before; every other transport keeps `<queue>.dlq` (the
53
+ attribute `App.dead_letter_suffix` is `None` instead of `".dlq"` until set). Deployments
54
+ that relied on the SQS default must name the DLQ `<queue>-dlq`, or pass `dead_letter_suffix=".dlq"` /
55
+ `dead_letter_queue=` to keep the old name. An explicitly empty `dead_letter_suffix=""` now counts as "no suffix" on SQS (`-dlq`); before, it made the DLQ the source queue itself.
56
+
57
+ ### Added
58
+ - **`babelqueue.transport.NativeSettler`**, an optional `Transport` capability
59
+ (`settle_native(message, reason, description)`). With the DLQ off the runtime calls it instead of
60
+ acknowledging a version-gated message (and, on the poison route, instead of the bounded retry) on
61
+ a transport that offers it; the decision follows the capability, never the transport's name.
62
+ `SqsTransport.settle_native` is a deliberate no-op on the wire; `AsbTransport.settle_native`
63
+ dead-letters natively (falling back to `abandon_message` for a receiver without
64
+ `dead_letter_message`). Transports without it are unchanged.
65
+ - **`babelqueue.transport.Abandoner` and `AsbTransport.abandon`.** When the dead-letter write of a
66
+ *version-gated* message fails, Azure Service Bus now `Abandon`s the peek-lock (broker-bindings 4.5,
67
+ "A failed write `Abandon`s it") so the broker redelivers it at once, instead of leaving it locked until
68
+ the lock lapses (up to 5 minutes). Only that branch abandons (owner decision 2026-10-25): `AsbTransport`
69
+ is deliberately **not** a `Rewinder`, so a graceful-shutdown release and a failed retry re-publish keep
70
+ their previous handling (a copy is re-published and the original completed), and a failed dead-letter
71
+ write of a poison body is still left to its lock. Tests pin all three. The `version_gate_asb` runner
72
+ asserts the `Abandon` on its `unsettled` rows.
73
+ - Conformance `version_gate_*` runners (`version_gate_pulsar` / `version_gate_artemis` through the
74
+ runtime, `version_gate_sqs` / `version_gate_asb` through the real transports) and their entries in
75
+ the results writer.
76
+
12
77
  ## [1.23.0] - 2026-10-25
13
78
 
14
79
  Implements ADR-0036 (post-R1 parity) decisions 2, 4, 5 and 6. A MINOR: every change is a
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: babelqueue
3
- Version: 1.23.0
3
+ Version: 1.24.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
@@ -212,8 +212,13 @@ untrimmed; Azure Service Bus and Artemis also accept the integer `1`). A message
212
212
  anything else is not decoded: it reaches the runtime on the reserved route
213
213
  `babelqueue:unsupported-schema-version:<v>` (the version percent-escaped) and is **never
214
214
  retried** — it goes straight to the dead-letter queue (the incoming body, byte-identical,
215
- with no `dead_letter` block) when `dead_letter` is enabled, and is dropped otherwise, whatever `max_attempts` is.
216
- It does not depend on a broker `RedrivePolicy`. If the dead-letter write fails the message is
215
+ with no `dead_letter` block) when `dead_letter` is enabled (or `on_unknown_urn=DEAD_LETTER`), and
216
+ `on_unknown_urn=DELETE` drops it, whatever `max_attempts` is. With `dead_letter` off, **SQS and Azure
217
+ Service Bus no longer drop it** (ADR-0037): it is left to the broker's native mechanism and logged
218
+ (SQS: no `DeleteMessage`, so visibility expiry redelivers it — the gate rejects it again — and the
219
+ queue's `RedrivePolicy` moves it to the native DLQ after `maxReceiveCount`; Azure Service Bus: an
220
+ explicit native `DeadLetter` with reason `unsupported schema version` into `$DeadLetterQueue`
221
+ instead of `Complete`). Kafka, Artemis and Pulsar still acknowledge and drop it. If the dead-letter write fails the message is
217
222
  left unacknowledged (an error is logged), is never retried and never reaches a handler; the
218
223
  dead-letter write is attempted again when the broker brings it back, which depends on the
219
224
  broker:
@@ -253,10 +258,17 @@ a warning. A **per-message delay** at publish time is
253
258
  standard queue; a larger value, or any per-message delay on a FIFO queue, raises
254
259
  `BabelQueueError` instead of being truncated or dropped).
255
260
 
261
+ **Poison bodies.** A body that does not decode, or has no identity, is never retried by the SDK
262
+ (ADR-0037): `dead_letter=True` writes the ADR-0035 record to the DLQ (`<queue>-dlq` on SQS, `<queue>.dlq` elsewhere) and then deletes it,
263
+ `on_unknown_urn=DELETE` deletes it, and with the DLQ off it is **not deleted and not re-sent**: the
264
+ transport's `settle_native` capability leaves it to SQS on the first delivery (visibility expiry,
265
+ then the queue's `RedrivePolicy`; the event is logged). Azure Service Bus does the same with a native
266
+ `DeadLetter` (reason `poison`). Every other transport keeps the bounded retry up to `max_attempts`.
267
+
256
268
  **Poison-loop risk:** a message that always fails is redelivered at once until
257
269
  `max_attempts` is reached. If the receive count is unavailable the SDK cannot
258
270
  advance attempts at all. **Configure a native `RedrivePolicy`**
259
- (`maxReceiveCount` ≥ `max_attempts`, pointing at `<queue>.dlq`) on every SQS
271
+ (`maxReceiveCount` ≥ `max_attempts`, pointing at `<queue>-dlq`, the SQS default DLQ name: FIFO `<queue>-dlq.fifo`, because an SQS queue name cannot contain a dot; `dead_letter_queue` overrides it, and `dead_letter_suffix` replaces `-dlq`, placed before `.fifo` on a FIFO queue) on every SQS
260
272
  queue as the broker-side backstop, and set a non-zero `retry_backoff` if the
261
273
  handler's dependencies need time to recover.
262
274
 
@@ -158,8 +158,13 @@ untrimmed; Azure Service Bus and Artemis also accept the integer `1`). A message
158
158
  anything else is not decoded: it reaches the runtime on the reserved route
159
159
  `babelqueue:unsupported-schema-version:<v>` (the version percent-escaped) and is **never
160
160
  retried** — it goes straight to the dead-letter queue (the incoming body, byte-identical,
161
- with no `dead_letter` block) when `dead_letter` is enabled, and is dropped otherwise, whatever `max_attempts` is.
162
- It does not depend on a broker `RedrivePolicy`. If the dead-letter write fails the message is
161
+ with no `dead_letter` block) when `dead_letter` is enabled (or `on_unknown_urn=DEAD_LETTER`), and
162
+ `on_unknown_urn=DELETE` drops it, whatever `max_attempts` is. With `dead_letter` off, **SQS and Azure
163
+ Service Bus no longer drop it** (ADR-0037): it is left to the broker's native mechanism and logged
164
+ (SQS: no `DeleteMessage`, so visibility expiry redelivers it — the gate rejects it again — and the
165
+ queue's `RedrivePolicy` moves it to the native DLQ after `maxReceiveCount`; Azure Service Bus: an
166
+ explicit native `DeadLetter` with reason `unsupported schema version` into `$DeadLetterQueue`
167
+ instead of `Complete`). Kafka, Artemis and Pulsar still acknowledge and drop it. If the dead-letter write fails the message is
163
168
  left unacknowledged (an error is logged), is never retried and never reaches a handler; the
164
169
  dead-letter write is attempted again when the broker brings it back, which depends on the
165
170
  broker:
@@ -199,10 +204,17 @@ a warning. A **per-message delay** at publish time is
199
204
  standard queue; a larger value, or any per-message delay on a FIFO queue, raises
200
205
  `BabelQueueError` instead of being truncated or dropped).
201
206
 
207
+ **Poison bodies.** A body that does not decode, or has no identity, is never retried by the SDK
208
+ (ADR-0037): `dead_letter=True` writes the ADR-0035 record to the DLQ (`<queue>-dlq` on SQS, `<queue>.dlq` elsewhere) and then deletes it,
209
+ `on_unknown_urn=DELETE` deletes it, and with the DLQ off it is **not deleted and not re-sent**: the
210
+ transport's `settle_native` capability leaves it to SQS on the first delivery (visibility expiry,
211
+ then the queue's `RedrivePolicy`; the event is logged). Azure Service Bus does the same with a native
212
+ `DeadLetter` (reason `poison`). Every other transport keeps the bounded retry up to `max_attempts`.
213
+
202
214
  **Poison-loop risk:** a message that always fails is redelivered at once until
203
215
  `max_attempts` is reached. If the receive count is unavailable the SDK cannot
204
216
  advance attempts at all. **Configure a native `RedrivePolicy`**
205
- (`maxReceiveCount` ≥ `max_attempts`, pointing at `<queue>.dlq`) on every SQS
217
+ (`maxReceiveCount` ≥ `max_attempts`, pointing at `<queue>-dlq`, the SQS default DLQ name: FIFO `<queue>-dlq.fifo`, because an SQS queue name cannot contain a dot; `dead_letter_queue` overrides it, and `dead_letter_suffix` replaces `-dlq`, placed before `.fifo` on a FIFO queue) on every SQS
206
218
  queue as the broker-side backstop, and set a non-zero `retry_backoff` if the
207
219
  handler's dependencies need time to recover.
208
220
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "1.23.0"
7
+ version = "1.24.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"
@@ -30,10 +30,13 @@ from .exceptions import BabelQueueError, DecryptError, UnknownUrnError
30
30
  from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
31
31
  from .routing import UnknownUrnStrategy
32
32
  from .transport import (
33
+ Abandoner,
33
34
  BatchPopper,
35
+ DeadLetterNamer,
34
36
  DeadLetterWriter,
35
37
  HeaderPublisher,
36
38
  InMemoryTransport,
39
+ NativeSettler,
37
40
  ReceivedMessage,
38
41
  Redeliverer,
39
42
  Rewinder,
@@ -42,7 +45,7 @@ from .transport import (
42
45
  Transport,
43
46
  )
44
47
 
45
- __version__ = "1.23.0"
48
+ __version__ = "1.24.0"
46
49
 
47
50
  __all__ = [
48
51
  "BabelQueue",
@@ -55,9 +58,12 @@ __all__ = [
55
58
  "Transport",
56
59
  "InMemoryTransport",
57
60
  "ReceivedMessage",
61
+ "Abandoner",
58
62
  "BatchPopper",
63
+ "DeadLetterNamer",
59
64
  "DeadLetterWriter",
60
65
  "HeaderPublisher",
66
+ "NativeSettler",
61
67
  "Redeliverer",
62
68
  "Rewinder",
63
69
  "RoutedPublisher",
@@ -43,8 +43,11 @@ from .replay import HEADER_REPLAY_BYPASS, _replay_scope
43
43
  from .routing import UnknownUrnStrategy
44
44
  from .transport import (
45
45
  NON_NUMERIC_ATTEMPTS_ROUTE,
46
+ Abandoner,
47
+ DeadLetterNamer,
46
48
  DeadLetterWriter,
47
49
  HeaderPublisher,
50
+ NativeSettler,
48
51
  PoisonRoute,
49
52
  ReceivedMessage,
50
53
  Redeliverer,
@@ -109,7 +112,7 @@ class BabelQueue:
109
112
  max_attempts: int = 3,
110
113
  dead_letter: bool = False,
111
114
  dead_letter_queue: Optional[str] = None,
112
- dead_letter_suffix: str = ".dlq",
115
+ dead_letter_suffix: Optional[str] = None,
113
116
  retry_backoff: float = 0.0,
114
117
  retry_backoff_multiplier: float = 1.0,
115
118
  retry_backoff_max: float = 0.0,
@@ -143,6 +146,8 @@ class BabelQueue:
143
146
  self.max_attempts = max_attempts
144
147
  self.dead_letter_enabled = bool(dead_letter)
145
148
  self.dead_letter_queue = dead_letter_queue
149
+ #: ``None`` = not set: the transport's :class:`~babelqueue.transport.DeadLetterNamer` default
150
+ #: (SQS: ``<queue>-dlq``) or else ``.dlq``. An explicit value is handed to the namer.
146
151
  self.dead_letter_suffix = dead_letter_suffix
147
152
  self.retry_backoff = float(retry_backoff)
148
153
  if retry_jitter not in RETRY_JITTERS:
@@ -383,7 +388,10 @@ class BabelQueue:
383
388
  received.queue,
384
389
  exc_info=exc,
385
390
  )
386
- self._rewind_unsettled(received)
391
+ if received.route_urn.startswith(_UNSUPPORTED_SCHEMA_ROUTE_PREFIX):
392
+ self._abandon_unsettled(received)
393
+ else:
394
+ self._rewind_unsettled(received)
387
395
  return
388
396
  self._retry_or_dead_letter(received, envelope, exc)
389
397
  else:
@@ -528,6 +536,49 @@ class BabelQueue:
528
536
  exc_info=True,
529
537
  )
530
538
 
539
+ def _settle_native(self, received: ReceivedMessage, reason: str, description: str) -> bool:
540
+ """Hand ``received`` to the transport's :class:`~babelqueue.transport.NativeSettler`
541
+ capability and report whether it did: ``False`` means the transport has none and the caller
542
+ keeps its previous settle. The event is logged and never retried by the SDK; a failing call
543
+ is logged too and leaves the message unacknowledged (the broker redelivers it)."""
544
+ if not isinstance(self.transport, NativeSettler):
545
+ return False
546
+ logger.warning(
547
+ "Left to the broker's native mechanism (not acknowledged, not retried): queue %r, "
548
+ "route %r, reason %s.",
549
+ received.queue, received.route_urn, reason,
550
+ )
551
+ try:
552
+ self.transport.settle_native(received, reason, description)
553
+ except Exception: # noqa: BLE001 - reported, never kills the loop
554
+ logger.error(
555
+ "Native settle of a message on queue %r failed; it stays unacknowledged and will "
556
+ "be redelivered by the broker.",
557
+ received.queue,
558
+ exc_info=True,
559
+ )
560
+ return True
561
+
562
+ def _abandon_unsettled(self, received: ReceivedMessage) -> None:
563
+ """A version-gated delivery whose dead-letter write failed: a :class:`Rewinder` transport is
564
+ rewound (:meth:`_rewind_unsettled`); an :class:`~babelqueue.transport.Abandoner` transport
565
+ (Azure Service Bus) abandons its lock so the broker redelivers it at once, after the same
566
+ short pause. Any other transport leaves it to its lock or visibility timeout."""
567
+ if isinstance(self.transport, Rewinder) or not isinstance(self.transport, Abandoner):
568
+ self._rewind_unsettled(received)
569
+ return
570
+ try:
571
+ self.transport.abandon(received)
572
+ except Exception: # noqa: BLE001 - reported, never kills the loop
573
+ logger.error(
574
+ "Failed to abandon an unsettled version-gated message (route %r) on queue %r.",
575
+ received.route_urn,
576
+ received.queue,
577
+ exc_info=True,
578
+ )
579
+ return
580
+ self._stop.wait(self._rewind_pause)
581
+
531
582
  def _rewind_unsettled(self, received: ReceivedMessage) -> None:
532
583
  """Make a deliberately unsettled message come back (cumulative-offset brokers such as Kafka
533
584
  would otherwise lose it when a later record on the partition is committed), after a short
@@ -717,10 +768,15 @@ class BabelQueue:
717
768
  * with the DLQ on (``dead_letter`` or the DEAD_LETTER strategy) it is written to the DLQ as
718
769
  the ADR-0035 record with ``reason: "poison"`` (original body preserved) and acknowledged
719
770
  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.
771
+ * with the DLQ off, on a transport with a native mechanism (SQS, Azure Service Bus: the
772
+ :class:`~babelqueue.transport.NativeSettler` capability) it is settled natively on the first
773
+ delivery -- not acknowledged, not re-published, not retried, body unchanged (ADR-0037
774
+ decision 2);
775
+ * with the DLQ off on any other transport it takes the bounded FAIL path (``RELEASE`` is
776
+ replaced by it, a poison body would otherwise be released forever): re-published with
777
+ ``attempts + 1`` up to ``max_attempts``, then dropped. The count is the larger of the
778
+ transport's own delivery count and the ``attempts`` of the bare body a previous retry left
779
+ behind.
724
780
 
725
781
  A failed DLQ write propagates: :meth:`dispatch` leaves the delivery unacknowledged."""
726
782
  if self.on_unknown_urn == UnknownUrnStrategy.DELETE:
@@ -734,6 +790,11 @@ class BabelQueue:
734
790
  if self.dead_letter_enabled or self.on_unknown_urn == UnknownUrnStrategy.DEAD_LETTER:
735
791
  self._settle_transport_poison(received, cause, attempts)
736
792
  return
793
+ # DLQ off, strategy neither delete nor a writable dead_letter: a transport with a native
794
+ # mechanism (SQS, Azure Service Bus) settles the poison body natively on the first delivery
795
+ # (ADR-0037 decision 2, "Go and Python") instead of the bounded retry below.
796
+ if self._settle_native(received, "poison", "the body does not decode into a valid envelope: " + cause):
797
+ return
737
798
  self._retry_or_dead_letter(
738
799
  received,
739
800
  {"attempts": attempts},
@@ -779,11 +840,20 @@ class BabelQueue:
779
840
  )
780
841
 
781
842
  def _dead_letter_target(self, received: ReceivedMessage) -> str:
782
- """The queue a dead-lettered delivery is written to (configured DLQ or ``<queue><suffix>``)."""
783
- return self.dead_letter_queue or (received.queue + self.dead_letter_suffix)
843
+ """The queue a dead-lettered delivery is written to: the configured ``dead_letter_queue``, else
844
+ the transport's :class:`~babelqueue.transport.DeadLetterNamer` name (SQS: ``<queue>-dlq``, FIFO
845
+ ``<queue>-dlq.fifo``, an explicit ``dead_letter_suffix`` placed before ``.fifo``; ADR-0037
846
+ decision 7), else ``<queue><dead_letter_suffix>`` (default ``.dlq``)."""
847
+ if self.dead_letter_queue:
848
+ return self.dead_letter_queue
849
+ if isinstance(self.transport, DeadLetterNamer):
850
+ return self.transport.dead_letter_queue_name(received.queue, self.dead_letter_suffix)
851
+ return received.queue + (".dlq" if self.dead_letter_suffix is None else self.dead_letter_suffix)
784
852
 
785
853
  def _settle_version_gated(self, received: ReceivedMessage, always: bool = False) -> None:
786
- """Dead-letter a version-gated delivery directly (no retry); drop it when no DLQ is set.
854
+ """Dead-letter a version-gated delivery directly (no retry). With no DLQ set it is left to the
855
+ transport's native mechanism when the transport has the :class:`~babelqueue.transport.NativeSettler`
856
+ capability (SQS, Azure Service Bus; ADR-0037 decision 1 rule 3) and dropped otherwise.
787
857
 
788
858
  ``always`` writes the DLQ record in every configuration, ignoring ``dead_letter_enabled``.
789
859
  A Kafka poison record reaches it with the DLQ already on (:meth:`_route_poison`). The Redis
@@ -798,7 +868,15 @@ class BabelQueue:
798
868
  if always or self.dead_letter_enabled or self.on_unknown_urn == UnknownUrnStrategy.DEAD_LETTER:
799
869
  self._write_dead_letter(received, self._dead_letter_target(received), received.body)
800
870
  return
801
- # No DLQ to hold it: the drop is deliberate, but it must not be silent.
871
+ # No DLQ to hold it (broker-bindings §5.7 rule 3). A transport with a native mechanism (SQS,
872
+ # Azure Service Bus) leaves it to that mechanism instead of dropping it (ADR-0037 decision 1).
873
+ if self._settle_native(
874
+ received,
875
+ "unsupported schema version",
876
+ "the message declares a schema version this consumer does not support",
877
+ ):
878
+ return
879
+ # Any other transport: the drop is deliberate, but it must not be silent.
802
880
  logger.warning(
803
881
  "Dropping an undeliverable message on queue %r (route %r, %d body characters): it can "
804
882
  "never be handled and no dead-letter queue is configured.",
@@ -12,6 +12,10 @@ Consuming uses the PeekLock reservation model (``receive_messages`` -> process -
12
12
  ``DeliveryCount`` (1-based), reconciled onto the envelope as ``attempts = DeliveryCount - 1``.
13
13
  A message left un-acked has its lock expire and is redelivered (at-least-once).
14
14
 
15
+ A version-gated message (``bq-schema-version`` the consumer does not support) or a poison body is, with
16
+ dead-lettering off, not completed: :meth:`AsbTransport.settle_native` dead-letters it natively into
17
+ ``$DeadLetterQueue`` (ADR-0037).
18
+
15
19
  This implements §4 of the broker-bindings contract. The envelope is unchanged
16
20
  (``schema_version`` stays 1); Azure Service Bus is purely additive.
17
21
 
@@ -269,6 +273,39 @@ class AsbTransport(Transport):
269
273
  return
270
274
  self._receiver(message.queue).complete_message(message.handle)
271
275
 
276
+ def abandon(self, message: ReceivedMessage) -> None:
277
+ """:class:`~babelqueue.transport.Abandoner`: abandon the peek-lock on ``message`` so the
278
+ broker redelivers it right away (``AbandonMessage``) instead of when the lock lapses.
279
+ Nothing is completed or dead-lettered and the body is untouched. The runtime calls it only
280
+ when the dead-letter write of a version-gated message failed (broker-bindings 4.5, "A failed
281
+ write ``Abandon``s it"). This transport is deliberately **not** a
282
+ :class:`~babelqueue.transport.Rewinder`, so a graceful-shutdown release and a failed retry
283
+ re-publish keep their previous handling (copy re-publish and ``Complete``). A message
284
+ without a handle, or a receiver without ``abandon_message`` (a test fake), leaves the lock to
285
+ lapse."""
286
+ if message.handle is None:
287
+ return
288
+ abandon = getattr(self._receiver(message.queue), "abandon_message", None)
289
+ if abandon is not None:
290
+ abandon(message.handle)
291
+
292
+ def settle_native(self, message: ReceivedMessage, reason: str, description: str) -> None:
293
+ """Dead-letter ``message`` natively (:class:`~babelqueue.transport.NativeSettler`, ADR-0037
294
+ decisions 1 and 2): a version-gated delivery or a poison body that the runtime may neither
295
+ process nor drop, with dead-lettering off, is moved by an explicit ``DeadLetter`` (reason
296
+ ``"unsupported schema version"`` or ``"poison"``, plus the description) into the entity's
297
+ ``$DeadLetterQueue``. Nothing is completed, re-published or retried, and the body is
298
+ unchanged. A receiver without ``dead_letter_message`` abandons the lock instead (also a
299
+ conformant native settle: ``MaxDeliveryCount`` takes over)."""
300
+ if message.handle is None:
301
+ return
302
+ receiver = self._receiver(message.queue)
303
+ dead_letter = getattr(receiver, "dead_letter_message", None)
304
+ if dead_letter is not None:
305
+ dead_letter(message.handle, reason=reason, error_description=description)
306
+ return
307
+ self.abandon(message)
308
+
272
309
  def close(self) -> None: # pragma: no cover - resource cleanup
273
310
  for resource in (*self._senders.values(), *self._receivers.values(), self._client):
274
311
  try:
@@ -25,6 +25,10 @@ contract keys win a collision, and the merge is bounded by SQS's 10-attribute li
25
25
  non-``bq-`` attributes back onto :attr:`~babelqueue.transport.ReceivedMessage.headers`. The frozen
26
26
  envelope is untouched (GR-1).
27
27
 
28
+ A version-gated message (``bq-schema-version`` the consumer does not support) or a poison body is, with
29
+ dead-lettering off, not deleted: :meth:`SqsTransport.settle_native` leaves it to SQS (visibility
30
+ expiry, then the queue's ``RedrivePolicy``; ADR-0037).
31
+
28
32
  This implements §3 of the broker-bindings contract. The envelope is unchanged
29
33
  (``schema_version`` stays 1); SQS is purely additive.
30
34
 
@@ -365,6 +369,31 @@ class SqsTransport(Transport):
365
369
  QueueUrl=self._resolve_url(message.queue), ReceiptHandle=message.handle
366
370
  )
367
371
 
372
+ def dead_letter_queue_name(self, queue: str, suffix: Optional[str] = None) -> str:
373
+ """:class:`~babelqueue.transport.DeadLetterNamer`: the BabelQueue DLQ of an SQS queue is
374
+ ``<queue>-dlq`` by default and, for a FIFO queue, ``<queue>-dlq.fifo``. An explicit ``suffix``
375
+ (``dead_letter_suffix``; ``None``/empty = none) replaces ``-dlq`` and is placed before
376
+ ``.fifo`` as well: an SQS FIFO queue name must end in ``.fifo``, so ``orders.fifo`` + ``-x`` is
377
+ ``orders-x.fifo``, never ``orders.fifo-x``. An SQS queue name may hold only alphanumerics,
378
+ hyphens and underscores (plus that one ``.fifo`` suffix), so the runtime-wide ``<queue>.dlq``
379
+ cannot be created on SQS (ADR-0037 decision 7). An explicit ``dead_letter_queue`` overrides it."""
380
+ suffix = suffix or "-dlq"
381
+ if queue.endswith(".fifo"):
382
+ return queue[: -len(".fifo")] + suffix + ".fifo"
383
+ return queue + suffix
384
+
385
+ def settle_native(self, message: ReceivedMessage, reason: str, description: str) -> None:
386
+ """Leave ``message`` to SQS itself (:class:`~babelqueue.transport.NativeSettler`, ADR-0037
387
+ decisions 1 and 2): a version-gated delivery or a poison body that the runtime may neither
388
+ process nor drop, with dead-lettering off. Deliberately does nothing on the wire: no
389
+ ``DeleteMessage``, no ``ChangeMessageVisibility``, no re-send. The message becomes visible
390
+ again when its visibility timeout lapses (the gate or the poison check rejects it again) and
391
+ the queue's ``RedrivePolicy`` moves it to the native DLQ once ``maxReceiveCount`` is reached;
392
+ without a ``RedrivePolicy`` it is redelivered until the retention period ends. The runtime
393
+ has already logged the event."""
394
+ if message.handle:
395
+ self._uncounted_handles.discard(message.handle)
396
+
368
397
  def redeliver(self, message: ReceivedMessage, body: str, delay: float) -> None:
369
398
  """Release ``message`` for redelivery after ``delay`` seconds (broker-bindings.md §3.5;
370
399
  :class:`~babelqueue.transport.Redeliverer`).
@@ -229,6 +229,59 @@ class Rewinder(Protocol):
229
229
  ...
230
230
 
231
231
 
232
+ @runtime_checkable
233
+ class DeadLetterNamer(Protocol):
234
+ """Optional :class:`Transport` capability: the name of the BabelQueue dead-letter queue for a
235
+ source queue, for a broker whose queue-name rules the runtime-wide ``<queue><suffix>`` shape cannot
236
+ satisfy. The runtime asks it whenever ``dead_letter_queue`` is not set, passing ``dead_letter_suffix``
237
+ (``None`` when not set). Amazon SQS implements it (a queue name cannot contain a dot and a FIFO name
238
+ must end in ``.fifo``): the default is ``<queue>-dlq``, FIFO ``<queue>-dlq.fifo``, and an explicit
239
+ suffix goes before ``.fifo`` too (``orders.fifo`` + ``-x`` -> ``orders-x.fifo``), never after it
240
+ (ADR-0037 decision 7). A transport without it keeps ``<queue><suffix>`` with the default ``.dlq``."""
241
+
242
+ def dead_letter_queue_name(self, queue: str, suffix: Optional[str] = None) -> str:
243
+ """The dead-letter queue name for ``queue`` (``suffix`` is the explicit one, or ``None``)."""
244
+ ...
245
+
246
+
247
+ @runtime_checkable
248
+ class Abandoner(Protocol):
249
+ """Optional :class:`Transport` capability: hand an unsettled *version-gated* delivery back to the
250
+ broker at once (Azure Service Bus ``Abandon``; broker-bindings.md section 4.5 "A failed write
251
+ ``Abandon``s it"; ADR-0037 decision 1 rule 5).
252
+
253
+ The runtime calls :meth:`abandon` only when the dead-letter write of a version-gated delivery
254
+ failed, instead of leaving it locked until the lock lapses. It is deliberately narrower than
255
+ :class:`Rewinder`: a transport that offers only this capability keeps its previous graceful
256
+ shutdown release and retry re-publish failure handling (copy re-publish and ack)."""
257
+
258
+ def abandon(self, message: ReceivedMessage) -> None:
259
+ """Release the broker lock on ``message`` so the broker redelivers it right away."""
260
+ ...
261
+
262
+
263
+ @runtime_checkable
264
+ class NativeSettler(Protocol):
265
+ """Optional :class:`Transport` capability: leave a delivery the runtime can neither process nor
266
+ safely drop to the broker's own mechanism, without acknowledging or deleting it and without
267
+ re-publishing or retrying it (ADR-0037 decisions 1 and 2).
268
+
269
+ The runtime calls :meth:`settle_native` instead of acknowledging when dead-lettering is off (and
270
+ the strategy is neither ``delete`` nor a writable ``dead_letter``) for a version-gated delivery
271
+ (broker-bindings.md section 5.7 rule 3) and for a poison body (error-handling.md section 7).
272
+ SQS implements it as "do nothing" (no ``DeleteMessage``: visibility expiry redelivers the message
273
+ and the queue's ``RedrivePolicy`` moves it to the native DLQ after ``maxReceiveCount``); Azure
274
+ Service Bus implements it with an explicit native ``DeadLetter(reason, description)`` into
275
+ ``$DeadLetterQueue``. The body is never altered. A transport that does not implement it keeps
276
+ the previous settle (acknowledge and drop), so the behaviour follows the capability, never the
277
+ transport's name."""
278
+
279
+ def settle_native(self, message: ReceivedMessage, reason: str, description: str) -> None:
280
+ """Leave ``message`` to the broker's native mechanism; ``reason`` and ``description`` say
281
+ why (e.g. ``"unsupported schema version"``, ``"poison"``)."""
282
+ ...
283
+
284
+
232
285
  #: Reserved ``route_urn`` of a record whose envelope carries a non-numeric ``attempts`` (a foreign
233
286
  #: producer). It sits under the version-gated prefix, so the runtime dead-letters it directly, raw
234
287
  #: and never retried (retrying needs a numeric attempt counter); ``redrive`` restores it to the DLQ