babelqueue 1.21.1__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 (152) hide show
  1. {babelqueue-1.21.1 → babelqueue-1.23.0}/.github/workflows/ci.yml +18 -3
  2. {babelqueue-1.21.1 → babelqueue-1.23.0}/.github/workflows/release.yml +17 -2
  3. {babelqueue-1.21.1 → babelqueue-1.23.0}/CHANGELOG.md +168 -0
  4. {babelqueue-1.21.1 → babelqueue-1.23.0}/PKG-INFO +110 -7
  5. {babelqueue-1.21.1 → babelqueue-1.23.0}/README.md +109 -6
  6. babelqueue-1.23.0/conformance-known-gaps.json +1 -0
  7. {babelqueue-1.21.1 → babelqueue-1.23.0}/pyproject.toml +1 -1
  8. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/__init__.py +3 -1
  9. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/_envelope_shape.py +3 -2
  10. babelqueue-1.23.0/src/babelqueue/_poison_record.py +92 -0
  11. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/app.py +314 -30
  12. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/artemis_transport.py +2 -1
  13. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/codec.py +46 -1
  14. babelqueue-1.23.0/src/babelqueue/kafka_retry.py +336 -0
  15. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/kafka_transport.py +193 -56
  16. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/pika_transport.py +3 -1
  17. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/pulsar_transport.py +35 -2
  18. babelqueue-1.23.0/src/babelqueue/redis_transport.py +671 -0
  19. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/sqs_transport.py +41 -3
  20. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/transport.py +33 -2
  21. babelqueue-1.23.0/tests/conformance/CONFORMANCE_VERSION +1 -0
  22. babelqueue-1.23.0/tests/conformance/manifest.json +6619 -0
  23. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/schema/results.example.json +1 -1
  24. babelqueue-1.23.0/tests/redis_backend.py +76 -0
  25. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_app.py +86 -4
  26. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_behaviour_conformance.py +623 -133
  27. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_conformance.py +14 -4
  28. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_kafka_integration.py +29 -6
  29. babelqueue-1.23.0/tests/test_kafka_retry.py +445 -0
  30. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_kafka_transport.py +191 -57
  31. babelqueue-1.23.0/tests/test_poison_routing.py +511 -0
  32. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_pulsar_transport.py +4 -0
  33. babelqueue-1.23.0/tests/test_redis_transport.py +1239 -0
  34. babelqueue-1.21.1/conformance-known-gaps.json +0 -84
  35. babelqueue-1.21.1/src/babelqueue/redis_transport.py +0 -355
  36. babelqueue-1.21.1/tests/conformance/CONFORMANCE_VERSION +0 -1
  37. babelqueue-1.21.1/tests/conformance/manifest.json +0 -1863
  38. babelqueue-1.21.1/tests/poison-trigger-set.json +0 -392
  39. babelqueue-1.21.1/tests/test_redis_transport.py +0 -504
  40. {babelqueue-1.21.1 → babelqueue-1.23.0}/.github/FUNDING.yml +0 -0
  41. {babelqueue-1.21.1 → babelqueue-1.23.0}/.github/dependabot.yml +0 -0
  42. {babelqueue-1.21.1 → babelqueue-1.23.0}/.gitignore +0 -0
  43. {babelqueue-1.21.1 → babelqueue-1.23.0}/LICENSE +0 -0
  44. {babelqueue-1.21.1 → babelqueue-1.23.0}/scripts/check_conformance_results.py +0 -0
  45. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/asb_transport.py +0 -0
  46. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/celery.py +0 -0
  47. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/contracts.py +0 -0
  48. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/dead_letter.py +0 -0
  49. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/django/__init__.py +0 -0
  50. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/django/apps.py +0 -0
  51. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/django/management/__init__.py +0 -0
  52. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  53. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  54. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/exceptions.py +0 -0
  55. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/gdpr.py +0 -0
  56. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/headers.py +0 -0
  57. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/idempotency.py +0 -0
  58. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/otel.py +0 -0
  59. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/outbox.py +0 -0
  60. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/py.typed +0 -0
  61. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/redrive.py +0 -0
  62. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/replay.py +0 -0
  63. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/routing.py +0 -0
  64. {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/schema.py +0 -0
  65. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/blank-ascii-job-urn-fallback.json +0 -0
  66. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
  67. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/dead-letter-roundtrip.json +0 -0
  68. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  69. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/duplicate-keys-last-wins.json +0 -0
  70. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
  71. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-job-and-urn.json +0 -0
  72. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-job-nonascii-space-and-urn.json +0 -0
  73. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
  74. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
  75. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
  76. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
  77. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
  78. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  79. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  80. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/invalid-wrong-case-job.json +0 -0
  81. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-array-urn-fallback.json +0 -0
  82. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-bool-urn-fallback.json +0 -0
  83. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-empty-string-urn-fallback.json +0 -0
  84. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-missing-job-array-urn.json +0 -0
  85. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-null-no-urn.json +0 -0
  86. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-null-urn-fallback.json +0 -0
  87. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-number-blank-urn.json +0 -0
  88. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-number-nonstring-urn.json +0 -0
  89. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-number-urn-fallback.json +0 -0
  90. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-object-urn-fallback.json +0 -0
  91. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/meta-ext-vendor-roundtrip.json +0 -0
  92. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/order-created.json +0 -0
  93. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-cr.json +0 -0
  94. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-ff.json +0 -0
  95. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-lf.json +0 -0
  96. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-space.json +0 -0
  97. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-tab.json +0 -0
  98. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-vt.json +0 -0
  99. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-cr.json +0 -0
  100. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-ff.json +0 -0
  101. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-lf.json +0 -0
  102. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-space.json +0 -0
  103. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-tab.json +0 -0
  104. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-vt.json +0 -0
  105. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-job-and-urn-both-blank.json +0 -0
  106. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-reemit-job-ascii-padded.json +0 -0
  107. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-reemit-job-unicode-padded.json +0 -0
  108. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-reemit-trace-id-padded.json +0 -0
  109. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-unicode-job-emsp.json +0 -0
  110. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-unicode-job-ideographic.json +0 -0
  111. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-unicode-job-nbsp.json +0 -0
  112. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-urn-only-ascii-blank.json +0 -0
  113. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-urn-only-unicode-space.json +0 -0
  114. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  115. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
  116. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
  117. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
  118. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  119. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/urn-only-roundtrip.json +0 -0
  120. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/wrong-case-keys-roundtrip.json +0 -0
  121. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  122. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/schema/results.schema.json +0 -0
  123. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/fakes/confluent_kafka/__init__.py +0 -0
  124. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/fakes/confluent_kafka/admin.py +0 -0
  125. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/fixtures/dead-lettered.json +0 -0
  126. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/fixtures/order-created.json +0 -0
  127. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_artemis_conformance.py +0 -0
  128. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_artemis_transport.py +0 -0
  129. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_asb_conformance.py +0 -0
  130. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_asb_transport.py +0 -0
  131. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_celery.py +0 -0
  132. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_codec.py +0 -0
  133. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_dead_letter.py +0 -0
  134. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_django.py +0 -0
  135. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_gdpr.py +0 -0
  136. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_headers.py +0 -0
  137. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_idempotency.py +0 -0
  138. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_kafka_conformance.py +0 -0
  139. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_kafka_integration_logic.py +0 -0
  140. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_otel.py +0 -0
  141. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_outbox.py +0 -0
  142. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_overhead.py +0 -0
  143. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_pika_transport.py +0 -0
  144. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_pulsar_conformance.py +0 -0
  145. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_redrive.py +0 -0
  146. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_reencode_regression.py +0 -0
  147. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_replay.py +0 -0
  148. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_schema.py +0 -0
  149. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_schema_conformance.py +0 -0
  150. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_shutdown.py +0 -0
  151. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_sqs_conformance.py +0 -0
  152. {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_sqs_transport.py +0 -0
@@ -12,7 +12,8 @@ permissions:
12
12
  jobs:
13
13
  test:
14
14
  name: Python ${{ matrix.python }}
15
- runs-on: ubuntu-latest
15
+ # setup-python ships no 3.9 build for ubuntu-26.04 (ubuntu-latest); keep 3.9 on 24.04 while it stays supported.
16
+ runs-on: ${{ matrix.python == '3.9' && 'ubuntu-24.04' || 'ubuntu-latest' }}
16
17
  strategy:
17
18
  fail-fast: false
18
19
  matrix:
@@ -104,6 +105,8 @@ jobs:
104
105
 
105
106
  - name: Run full suite with coverage gate (>=90%)
106
107
  env:
108
+ # A real redis:7 is mandatory here: no silent skip or fakeredis fallback (shared runner contract).
109
+ BQ_REQUIRE_REDIS: "1"
107
110
  BABELQUEUE_TEST_REDIS: redis://localhost:6379/0
108
111
  BABELQUEUE_TEST_AMQP: amqp://guest:guest@localhost:5672/
109
112
  SQS_ENDPOINT: http://localhost:9324
@@ -162,9 +165,19 @@ jobs:
162
165
  conformance:
163
166
  name: Conformance suite in sync + results.json
164
167
  runs-on: ubuntu-latest
168
+ services:
169
+ redis:
170
+ image: redis:7
171
+ ports:
172
+ - 6379:6379
173
+ options: >-
174
+ --health-cmd "redis-cli ping"
175
+ --health-interval 5s
176
+ --health-timeout 3s
177
+ --health-retries 10
165
178
  env:
166
179
  # The pinned conformance release (ADR-0032). Bump together with tests/conformance/.
167
- BQ_CONFORMANCE_VERSION: 1.5.0
180
+ BQ_CONFORMANCE_VERSION: 1.7.0
168
181
  # sha256 of that release's fetch.sh: the script runs on the runner, so it is pinned rather
169
182
  # than trusted from a mutable tag. Bump together with BQ_CONFORMANCE_VERSION.
170
183
  BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849
@@ -190,11 +203,13 @@ jobs:
190
203
  - name: Install
191
204
  run: |
192
205
  python -m pip install --upgrade pip
193
- pip install -e ".[dev,celery,django,otel]" opentelemetry-sdk jsonschema
206
+ pip install -e ".[dev,celery,django,otel,redis]" opentelemetry-sdk jsonschema
194
207
 
195
208
  - name: Run the conformance suite and write results.json
196
209
  env:
197
210
  BQ_CONFORMANCE_RESULTS: ${{ runner.temp }}/results.json
211
+ BQ_REQUIRE_REDIS: "1"
212
+ BABELQUEUE_TEST_REDIS: redis://localhost:6379/0
198
213
  run: pytest -q tests/test_conformance.py
199
214
 
200
215
  - name: Validate results.json (schema, bindings, sections)
@@ -14,9 +14,21 @@ 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,
18
+ # exactly as in ci.yml's `conformance` job — without it the cases are reported as failures.
19
+ services:
20
+ redis:
21
+ image: redis:7
22
+ ports:
23
+ - 6379:6379
24
+ options: >-
25
+ --health-cmd "redis-cli ping"
26
+ --health-interval 5s
27
+ --health-timeout 3s
28
+ --health-retries 10
17
29
  env:
18
30
  # The pinned conformance release (ADR-0032). Keep identical to ci.yml.
19
- BQ_CONFORMANCE_VERSION: 1.5.0
31
+ BQ_CONFORMANCE_VERSION: 1.7.0
20
32
  BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849
21
33
  permissions:
22
34
  id-token: write # PyPI Trusted Publishing (OIDC) — no API token needed
@@ -40,9 +52,12 @@ jobs:
40
52
  - name: Install & test (writes results.json)
41
53
  env:
42
54
  BQ_CONFORMANCE_RESULTS: ${{ runner.temp }}/results.json
55
+ # A real redis:7 is mandatory: no silent skip or fakeredis fallback (shared runner contract).
56
+ BQ_REQUIRE_REDIS: "1"
57
+ BABELQUEUE_TEST_REDIS: redis://localhost:6379/0
43
58
  run: |
44
59
  python -m pip install --upgrade pip build
45
- pip install -e ".[dev,celery,django,otel]" opentelemetry-sdk jsonschema
60
+ pip install -e ".[dev,celery,django,otel,redis]" opentelemetry-sdk jsonschema
46
61
  pytest
47
62
  test -s "$BQ_CONFORMANCE_RESULTS" # the conformance run must have produced results.json
48
63
 
@@ -9,6 +9,174 @@ 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
+
82
+ ## [1.22.0] - 2026-10-24
83
+
84
+ Implements ADR-0033 (Redis) and ADR-0034 (Kafka tiers, backoff, poison). The envelope stays at
85
+ `schema_version: 1`; the vendored conformance suite moves to v1.6.0 and
86
+ `conformance-known-gaps.json` is now empty.
87
+
88
+ ### Changed
89
+ - The Redis poison dead-letter element is now the ADR-0035 record (raw splice, or the `raw`/`raw_encoding` wrapper) carrying a `dead_letter` block, instead of the bare element (broker-bindings section 1). A legacy `__bq_frame` header is unwrapped, not re-applied. Dropped the dead `_reject_json_constant` helper in the Kafka transport.
90
+ - **Redis: a stored value that is not valid UTF-8 no longer crashes the reserve.** The client built from a URL now uses `encoding_errors="surrogateescape"`, replies are decoded losslessly, and the settle / release / dead-letter calls send the exact stored bytes back, so such a poison body becomes the ADR-0035 base64 wrapper (no U+FFFD) and its reservation is actually removed. A client you pass in with `decode_responses=True` should set `encoding_errors="surrogateescape"` as well (redis-py's default strict decoding raises on such a reply).
91
+ - **Poison DLQ record shape follows ADR-0035** (Kafka): a strict JSON object body is spliced byte for byte with a `dead_letter` member, anything else is wrapped as `{"raw": ..., "raw_encoding"?: "base64", "dead_letter": ...}`; a body that is not valid UTF-8 is stored losslessly as base64 instead of with U+FFFD, and the conformance `poison_dlq` section runs.
92
+
93
+ ### Fixed
94
+ - **Invalid UTF-8 is poison in both Redis layouts (broker-bindings section 1.1).** A stored value (or zset `:reserved` member) that is not valid UTF-8 is dead-lettered on first delivery as the ADR-0035 `raw`/`raw_encoding: "base64"` wrapper, even when the codec or `cjson` could decode it: it is never handed to a handler and never retried, released or re-published, and the exact stored bytes are used for the settle script, so no byte is rewritten.
95
+ - **Redis, Laravel-compatible layout: a poison reservation is dead-lettered in every
96
+ configuration.** The DLQ write no longer depends on `dead_letter_enabled` (default `False`) or
97
+ `on_unknown_urn`: an undecodable body or one without a numeric `attempts` is written raw to
98
+ `<queue>.dlq` and acked instead of being logged and dropped (broker-bindings section 1.1,
99
+ ADR-0033; same as the Go reference). Other version-gated routes keep their previous behaviour.
100
+ - **Redis, Laravel-compatible layout: a job with a non-numeric `attempts` or an undecodable body no
101
+ longer aborts the reserve script.** It is reserved raw (`pcall` guard), dead-lettered byte for
102
+ byte and acked, leaving nothing in `:reserved` (ADR-0033 (c)).
103
+ - **Redis, legacy layout: a `kill -9` no longer strands a message in `<queue>:processing`.** Every
104
+ reservation writes a `<token>:<raw>` member into `<queue>:processing:deadlines`; a sweep before
105
+ each reserve moves lapsed reservations back to the end of `<queue>`. Ack, release and dead-letter
106
+ are gated on their own reservation, so a late ack never touches a newer one. The legacy mode
107
+ remains the default and the `<queue>:processing` key name is unchanged.
108
+ - **Kafka without retry tiers: a failing handler no longer stops the worker** (Go
109
+ `leaveRepublishUnsettled` parity). The transport still raises `BabelQueueError` (section 6.5), but
110
+ the runtime now catches a failed retry release, logs it (`babelqueue.app`, ERROR, "NOT
111
+ acknowledged"), leaves the record unsettled (no commit; a rewind-capable transport seeks back and
112
+ the loop pauses `_rewind_pause` first) and keeps consuming. Previously the error escaped
113
+ `consume()`. Applies to any transport whose retry release fails.
114
+ - **Kafka poison follows Go `routePoison` when no DLQ is configured.** With the DLQ on
115
+ (`dead_letter=True` or `on_unknown_urn=DEAD_LETTER`) the wrapped record is written to
116
+ `<topic>.dlq` (`dead_letter.reason: "poison"`) and then committed. With the DLQ off the poison
117
+ record is **not** written anywhere: `on_unknown_urn=DELETE` drops it, every other strategy takes
118
+ the bounded FAIL path (retried through the retry tiers up to `max_attempts` with a bare
119
+ `{"attempts": N}` body, then dropped; without tiers the record is redelivered in place).
120
+ Operational note: when the DLQ is on, `<topic>.dlq` must exist; a failed write leaves the record
121
+ uncommitted and rewound (it is never dropped).
122
+ - **Unknown-URN `RELEASE` and the retry release leave the delivery unsettled on failure** (Go
123
+ `leaveRepublishUnsettled`): a failed re-publish of an unknown-URN message is no longer counted as
124
+ a handler failure and can no longer lead to the record being acked and lost.
125
+ - **Redis, legacy `:processing` layout: an undecodable body is dead-lettered on its first
126
+ delivery.** A body Go's typed `Decode` rejects (neither a JSON object nor `null`, or wrong member types such as
127
+ a string `attempts`/`meta`; same criterion as Kafka poison) is written verbatim to `<queue>.dlq`
128
+ through the gated settle script and removed from `:processing`, in every configuration, instead of
129
+ being retried until `max_attempts`. A valid JSON object without an identity keeps the existing
130
+ bounded path.
131
+ - **Kafka: a record without a valid `job` / `urn` is poison** (ADR-0034 section 10), not an
132
+ unknown URN: with the DLQ on it is dead-lettered wrapped with `dead_letter.reason: "poison"` and
133
+ never retried; with the DLQ off it follows Go `routePoison` (see above).
134
+
135
+ - **Redis, Laravel-compatible layout: the reserve script no longer aborts on a body that decodes
136
+ but cannot be re-encoded** (`cjson.encode` of `inf`/`NaN`, e.g. `1e400`, raised after the `LPOP`
137
+ and lost the job). The encode is now inside `pcall`; the job is reserved raw and takes the poison
138
+ path. Note: fakeredis' `cjson` serialises `inf` as `Infinity` and masks this; it is proven by a
139
+ real-Redis test (CI `redis:7`) and by a Lua test against a failing `cjson` stub.
140
+ - **`on_unknown_urn="delete"` no longer discards a Redis poison reservation.** It is dead-lettered
141
+ byte for byte like the Kafka poison record already was; a genuine unknown URN is still deleted.
142
+ - **Redis, Laravel-compatible layout: `dead_letter` is atomic and gated.** One script `ZREM`s the
143
+ reservation and writes the dead-letter record and its notify entry; an expired reservation writes
144
+ nothing (and logs a warning) instead of publishing a duplicate.
145
+ - **A message dropped because it is undeliverable and no dead-letter queue is configured is
146
+ logged** (`babelqueue.app`, WARNING) instead of disappearing silently.
147
+ - **Kafka `RetryReinjector` no longer loses records:** it kept one held record per partition, so a
148
+ second record on the same partition overwrote the first. It now keeps all of them in order.
149
+ - **A worker on a tier-less Kafka transport with `max_attempts` > 1 warns at start-up** that a
150
+ failing handler cannot be retried: the record is left uncommitted and redelivered.
151
+ - Internal: the legacy `:processing` settle script takes `[token, raw]` and builds the deadline
152
+ member itself.
153
+
154
+ ### Added
155
+ - `RedisTransport(retry_after=90, migration_batch_size=-1)` (also `?retry_after=` in the URL;
156
+ `None` / `null` disables recovery). The Laravel-compatible `retry_after` default is now 90 s
157
+ (was 60 s).
158
+ - SQS: `SqsTransport.publish(..., delay_seconds=)` (`DelaySeconds` 0..900; out of range or FIFO
159
+ raises `BabelQueueError`).
160
+ - Kafka: `KafkaTransport(retry_tiers_ms=...)` with `DEFAULT_RETRY_TIERS_MS`, `retry_topic()` and
161
+ the `babelqueue.kafka_retry.RetryReinjector` consumer that moves parked records from
162
+ `<topic>.retry.<n>` back to the work topic (broker-bindings section 6.4). Opt-in: without tiers a
163
+ retry / `publish_delayed` raises `BabelQueueError` (the transport raises; the worker logs it and
164
+ leaves the record uncommitted, see Fixed).
165
+ - Worker-side retry backoff: `BabelQueue(retry_backoff=, retry_backoff_multiplier=,
166
+ retry_backoff_max=, retry_jitter=)` (ADR-0034 section 9); the defaults keep an immediate release.
167
+ - Conformance runner covers the v1.6.0 sections (`redis_layout`, `redis_delay`, `retry_backoff`,
168
+ `kafka_poison`, ...). CI runs the Redis cases against a real `redis:7` with `BQ_REQUIRE_REDIS=1`.
169
+
170
+ ### Changed
171
+ - **Kafka retries go to the retry tiers.** With `retry_tiers_ms` set a failed message is parked on
172
+ `<queue>.retry.<n>` and needs a running `RetryReinjector`; the retry topics must exist. Without
173
+ tiers a retry now raises from the transport instead of republishing onto the work topic; the
174
+ runtime logs it and redelivers the record rather than stopping the worker.
175
+
176
+ ### Documentation
177
+ - README: Redis layouts, the limit that the Laravel-compatible layout carries no out-of-band headers (ADR-0033 §5),
178
+ Kafka retry tiers, `LogAppendTime` on the tier topics, retry backoff, SQS `delay_seconds`.
179
+
12
180
  ## [1.21.1] - 2026-10-20
13
181
 
14
182
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: babelqueue
3
- Version: 1.21.1
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
@@ -195,7 +195,7 @@ app.run() # consume forever (Ctrl-C to stop)
195
195
  messages on `<queue>.dlq`. `on_unknown_urn` = `fail` | `delete` | `release` | `dead_letter`.
196
196
  - **Transports:** `redis://` (reliable-queue pattern; add `?laravel=1` to share a
197
197
  Redis queue with a Laravel BabelQueue worker using its reserved-set semantics —
198
- see below), `amqp://` (RabbitMQ via `pika`, with the contract AMQP properties)
198
+ see below), `kafka://` (opt-in retry tiers, see below), `amqp://` (RabbitMQ via `pika`, with the contract AMQP properties)
199
199
  and `memory://` (in-process, great for tests/local). Bring your own by passing
200
200
  `transport=...`.
201
201
 
@@ -248,7 +248,10 @@ transport **always** releases with `ChangeMessageVisibility` (broker-bindings
248
248
  attempt counter, so a shutdown release also consumes an attempt. The default
249
249
  delay is **0 s** (`retry_backoff` / `unknown_urn_release_delay`), i.e. the
250
250
  message is visible again immediately; delays outside 0..43200 s are clamped with
251
- a warning.
251
+ a warning. A **per-message delay** at publish time is
252
+ `SqsTransport.publish(queue, body, delay_seconds=n)` (`DelaySeconds`, 0..900 on a
253
+ standard queue; a larger value, or any per-message delay on a FIFO queue, raises
254
+ `BabelQueueError` instead of being truncated or dropped).
252
255
 
253
256
  **Poison-loop risk:** a message that always fails is redelivered at once until
254
257
  `max_attempts` is reached. If the receive count is unavailable the SDK cannot
@@ -257,11 +260,98 @@ advance attempts at all. **Configure a native `RedrivePolicy`**
257
260
  queue as the broker-side backstop, and set a non-zero `retry_backoff` if the
258
261
  handler's dependencies need time to recover.
259
262
 
263
+ ### Retry back-off
264
+
265
+ `BabelQueue(..., retry_backoff=, retry_backoff_multiplier=, retry_backoff_max=, retry_jitter=)`
266
+ set the worker-side retry delay (ADR-0034 section 9): after the n-th failure the message is
267
+ released with `d(n) = min(retry_backoff_max, retry_backoff * retry_backoff_multiplier ** (n - 1))`
268
+ seconds (`retry_backoff_max=0` means no cap) and `retry_jitter="full"` draws `uniform(0, d(n))`.
269
+ The defaults (`retry_backoff=0`, multiplier 1, jitter `"none"`) keep the previous behaviour: an
270
+ immediate release. How the delay is honoured depends on the transport: SQS uses it as the
271
+ visibility timeout, the Laravel-compatible Redis layout as the `:delayed` score and Kafka picks a
272
+ retry tier (see below).
273
+
274
+ ### Kafka retry tiers and the re-inject consumer
275
+
276
+ Kafka has no per-message delay, so delayed delivery and retry back-off are SDK-owned **retry
277
+ topics** (broker-bindings section 6.4, ADR-0034). They are opt-in: pass `retry_tiers_ms` to the
278
+ transport (the recommended set is `DEFAULT_RETRY_TIERS_MS` = 5 s, 30 s, 5 min, 30 min):
279
+
280
+ ```python
281
+ from babelqueue import BabelQueue
282
+ from babelqueue.kafka_transport import KafkaTransport, DEFAULT_RETRY_TIERS_MS
283
+ from babelqueue.kafka_retry import RetryReinjector
284
+
285
+ transport = KafkaTransport("kafka://localhost:9092", retry_tiers_ms=DEFAULT_RETRY_TIERS_MS)
286
+ app = BabelQueue(transport=transport, queue="orders", max_attempts=5, dead_letter=True)
287
+
288
+ # Run one next to the workers, otherwise a parked message is never delivered again:
289
+ RetryReinjector("kafka://localhost:9092", work_topics=["orders"],
290
+ retry_tiers_ms=DEFAULT_RETRY_TIERS_MS).run()
291
+ ```
292
+
293
+ A failed message is republished (attempts + 1) to `<queue>.retry.<n>`, where
294
+ `n = min(attempts, N - 1) + 1`; `publish_delayed` picks the smallest tier that covers the requested
295
+ delay. Create the `<queue>.retry.<n>` topics up front. The `RetryReinjector` copies each record's
296
+ value and key byte for byte back to its work topic once the tier delay has elapsed (headers
297
+ `bq-delay` / `bq-original-topic` are dropped) and commits only after the broker confirmed the
298
+ write, so delivery stays at-least-once. It holds every record it has accepted per partition (oldest
299
+ first, a record never overtakes the one before it) and a failed write rewinds the partition to the
300
+ failed record. Without `retry_tiers_ms`, `publish_delayed` and a retry
301
+ raise `BabelQueueError` and nothing is produced. The worker does not stop on that error: it logs
302
+ it, leaves the record uncommitted (the consumer seeks back to it and the loop pauses briefly, so
303
+ the record is redelivered and never lost) and keeps consuming. A worker started without tiers
304
+ while `max_attempts` is above 1 logs a warning at start-up: a failing handler cannot be retried
305
+ (the record keeps failing in place) until tiers are configured. The re-inject delay is measured from the record
306
+ timestamp: set `message.timestamp.type=LogAppendTime` on the `.retry.<n>` topics so that
307
+ timestamp is the broker's, not a producer clock's.
308
+
309
+ **Records without an identity are poison.** A Kafka record whose JSON object has neither a valid
310
+ string `job` nor a valid string `urn` (ADR-0034 section 10) is poison, not an unknown URN. With
311
+ the DLQ on (`dead_letter` or `on_unknown_urn=DEAD_LETTER`) it is dead-lettered on its first delivery
312
+ (wrapped with `dead_letter.reason: "poison"`, key preserved) and never retried: the record is
313
+ written to `<topic>.dlq` (which must exist) and its offset is committed once that write
314
+ is confirmed; a failed write leaves it uncommitted and rewound. With the DLQ off nothing is written:
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 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
341
+ delivery and removed from `:processing`.
342
+
260
343
  ### Sharing a Redis queue with Laravel
261
344
 
262
345
  By default the Redis transport owns its queue end-to-end (`RPUSH` to produce;
263
- `BLMOVE` into a `<queue>:processing` list to reserve; `LREM` on ack). To consume a
264
- **shared** Laravel BabelQueue Redis queue instead, enable Laravel-compatible mode:
346
+ `BLMOVE` into a `<queue>:processing` list to reserve; `LREM` on ack). Each reservation also
347
+ writes a `<token>:<raw>` member (32 lowercase hex characters of token) into the
348
+ `<queue>:processing:deadlines` sorted set, and before every reserve a sweep moves the lapsed
349
+ reservations back to the end of the list, so a `kill -9` no longer strands a message in
350
+ `:processing`; a late ack of a swept reservation touches nothing of the newer one. `retry_after`
351
+ (default **90** seconds; `None` / `?retry_after=null` disables recovery) sets that deadline, and
352
+ `migration_batch_size` (`-1` = all, otherwise `>= 1`) bounds how many due members one sweep or
353
+ migration moves. To consume a **shared** Laravel BabelQueue Redis queue instead, enable
354
+ Laravel-compatible mode:
265
355
 
266
356
  ```python
267
357
  app = BabelQueue("redis://localhost:6379/0?laravel=1", queue="orders")
@@ -278,8 +368,21 @@ member a Python worker writes is identical to a Laravel worker's — either side
278
368
  ack (`ZREM`) the other's reservation, so a Python worker and a Laravel worker share
279
369
  one queue without losing or double-processing messages. Before each pop, expired
280
370
  reserved/delayed jobs migrate back to the ready list, so a crashed worker's
281
- in-flight job is re-reserved. Options: `?prefix=queues:` and `?retry_after=60`
282
- (or the `key_prefix` / `retry_after` constructor args).
371
+ in-flight job is re-reserved. Options: `?prefix=queues:` and `?retry_after=90`
372
+ (or the `key_prefix` / `retry_after` / `migration_batch_size` constructor args). A job whose body
373
+ does not decode, or whose `attempts` is not a number, is reserved raw, dead-lettered byte for byte
374
+ and acked, so it never lingers in `:reserved`. That holds when the body cannot be re-encoded
375
+ either (for example a number such as `1e400`, which real Redis' `cjson` refuses to serialise), and
376
+ it holds under `on_unknown_urn="delete"` too: such a message is dead-lettered, never deleted. Without
377
+ a dead-letter queue it is dropped, and the drop is logged as a warning. Dead-lettering a
378
+ reserved message is one atomic script (`ZREM` the reservation, then `RPUSH` the dead-letter
379
+ record and its notify entry); if the reservation expired first, nothing is written and a warning
380
+ says so, because the job was already migrated back and will be delivered again.
381
+
382
+ **Limit (A3):** the Laravel-compatible layout stores the bare envelope (Laravel's reserve script
383
+ has to decode it), so out-of-band transport headers such as the W3C `traceparent` cannot be
384
+ carried there. They are dropped without an error; the `trace_id` inside the envelope is still
385
+ preserved. The default (legacy) layout carries them.
283
386
 
284
387
  ## Framework adapters — Celery & Django
285
388
 
@@ -141,7 +141,7 @@ app.run() # consume forever (Ctrl-C to stop)
141
141
  messages on `<queue>.dlq`. `on_unknown_urn` = `fail` | `delete` | `release` | `dead_letter`.
142
142
  - **Transports:** `redis://` (reliable-queue pattern; add `?laravel=1` to share a
143
143
  Redis queue with a Laravel BabelQueue worker using its reserved-set semantics —
144
- see below), `amqp://` (RabbitMQ via `pika`, with the contract AMQP properties)
144
+ see below), `kafka://` (opt-in retry tiers, see below), `amqp://` (RabbitMQ via `pika`, with the contract AMQP properties)
145
145
  and `memory://` (in-process, great for tests/local). Bring your own by passing
146
146
  `transport=...`.
147
147
 
@@ -194,7 +194,10 @@ transport **always** releases with `ChangeMessageVisibility` (broker-bindings
194
194
  attempt counter, so a shutdown release also consumes an attempt. The default
195
195
  delay is **0 s** (`retry_backoff` / `unknown_urn_release_delay`), i.e. the
196
196
  message is visible again immediately; delays outside 0..43200 s are clamped with
197
- a warning.
197
+ a warning. A **per-message delay** at publish time is
198
+ `SqsTransport.publish(queue, body, delay_seconds=n)` (`DelaySeconds`, 0..900 on a
199
+ standard queue; a larger value, or any per-message delay on a FIFO queue, raises
200
+ `BabelQueueError` instead of being truncated or dropped).
198
201
 
199
202
  **Poison-loop risk:** a message that always fails is redelivered at once until
200
203
  `max_attempts` is reached. If the receive count is unavailable the SDK cannot
@@ -203,11 +206,98 @@ advance attempts at all. **Configure a native `RedrivePolicy`**
203
206
  queue as the broker-side backstop, and set a non-zero `retry_backoff` if the
204
207
  handler's dependencies need time to recover.
205
208
 
209
+ ### Retry back-off
210
+
211
+ `BabelQueue(..., retry_backoff=, retry_backoff_multiplier=, retry_backoff_max=, retry_jitter=)`
212
+ set the worker-side retry delay (ADR-0034 section 9): after the n-th failure the message is
213
+ released with `d(n) = min(retry_backoff_max, retry_backoff * retry_backoff_multiplier ** (n - 1))`
214
+ seconds (`retry_backoff_max=0` means no cap) and `retry_jitter="full"` draws `uniform(0, d(n))`.
215
+ The defaults (`retry_backoff=0`, multiplier 1, jitter `"none"`) keep the previous behaviour: an
216
+ immediate release. How the delay is honoured depends on the transport: SQS uses it as the
217
+ visibility timeout, the Laravel-compatible Redis layout as the `:delayed` score and Kafka picks a
218
+ retry tier (see below).
219
+
220
+ ### Kafka retry tiers and the re-inject consumer
221
+
222
+ Kafka has no per-message delay, so delayed delivery and retry back-off are SDK-owned **retry
223
+ topics** (broker-bindings section 6.4, ADR-0034). They are opt-in: pass `retry_tiers_ms` to the
224
+ transport (the recommended set is `DEFAULT_RETRY_TIERS_MS` = 5 s, 30 s, 5 min, 30 min):
225
+
226
+ ```python
227
+ from babelqueue import BabelQueue
228
+ from babelqueue.kafka_transport import KafkaTransport, DEFAULT_RETRY_TIERS_MS
229
+ from babelqueue.kafka_retry import RetryReinjector
230
+
231
+ transport = KafkaTransport("kafka://localhost:9092", retry_tiers_ms=DEFAULT_RETRY_TIERS_MS)
232
+ app = BabelQueue(transport=transport, queue="orders", max_attempts=5, dead_letter=True)
233
+
234
+ # Run one next to the workers, otherwise a parked message is never delivered again:
235
+ RetryReinjector("kafka://localhost:9092", work_topics=["orders"],
236
+ retry_tiers_ms=DEFAULT_RETRY_TIERS_MS).run()
237
+ ```
238
+
239
+ A failed message is republished (attempts + 1) to `<queue>.retry.<n>`, where
240
+ `n = min(attempts, N - 1) + 1`; `publish_delayed` picks the smallest tier that covers the requested
241
+ delay. Create the `<queue>.retry.<n>` topics up front. The `RetryReinjector` copies each record's
242
+ value and key byte for byte back to its work topic once the tier delay has elapsed (headers
243
+ `bq-delay` / `bq-original-topic` are dropped) and commits only after the broker confirmed the
244
+ write, so delivery stays at-least-once. It holds every record it has accepted per partition (oldest
245
+ first, a record never overtakes the one before it) and a failed write rewinds the partition to the
246
+ failed record. Without `retry_tiers_ms`, `publish_delayed` and a retry
247
+ raise `BabelQueueError` and nothing is produced. The worker does not stop on that error: it logs
248
+ it, leaves the record uncommitted (the consumer seeks back to it and the loop pauses briefly, so
249
+ the record is redelivered and never lost) and keeps consuming. A worker started without tiers
250
+ while `max_attempts` is above 1 logs a warning at start-up: a failing handler cannot be retried
251
+ (the record keeps failing in place) until tiers are configured. The re-inject delay is measured from the record
252
+ timestamp: set `message.timestamp.type=LogAppendTime` on the `.retry.<n>` topics so that
253
+ timestamp is the broker's, not a producer clock's.
254
+
255
+ **Records without an identity are poison.** A Kafka record whose JSON object has neither a valid
256
+ string `job` nor a valid string `urn` (ADR-0034 section 10) is poison, not an unknown URN. With
257
+ the DLQ on (`dead_letter` or `on_unknown_urn=DEAD_LETTER`) it is dead-lettered on its first delivery
258
+ (wrapped with `dead_letter.reason: "poison"`, key preserved) and never retried: the record is
259
+ written to `<topic>.dlq` (which must exist) and its offset is committed once that write
260
+ is confirmed; a failed write leaves it uncommitted and rewound. With the DLQ off nothing is written:
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 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
287
+ delivery and removed from `:processing`.
288
+
206
289
  ### Sharing a Redis queue with Laravel
207
290
 
208
291
  By default the Redis transport owns its queue end-to-end (`RPUSH` to produce;
209
- `BLMOVE` into a `<queue>:processing` list to reserve; `LREM` on ack). To consume a
210
- **shared** Laravel BabelQueue Redis queue instead, enable Laravel-compatible mode:
292
+ `BLMOVE` into a `<queue>:processing` list to reserve; `LREM` on ack). Each reservation also
293
+ writes a `<token>:<raw>` member (32 lowercase hex characters of token) into the
294
+ `<queue>:processing:deadlines` sorted set, and before every reserve a sweep moves the lapsed
295
+ reservations back to the end of the list, so a `kill -9` no longer strands a message in
296
+ `:processing`; a late ack of a swept reservation touches nothing of the newer one. `retry_after`
297
+ (default **90** seconds; `None` / `?retry_after=null` disables recovery) sets that deadline, and
298
+ `migration_batch_size` (`-1` = all, otherwise `>= 1`) bounds how many due members one sweep or
299
+ migration moves. To consume a **shared** Laravel BabelQueue Redis queue instead, enable
300
+ Laravel-compatible mode:
211
301
 
212
302
  ```python
213
303
  app = BabelQueue("redis://localhost:6379/0?laravel=1", queue="orders")
@@ -224,8 +314,21 @@ member a Python worker writes is identical to a Laravel worker's — either side
224
314
  ack (`ZREM`) the other's reservation, so a Python worker and a Laravel worker share
225
315
  one queue without losing or double-processing messages. Before each pop, expired
226
316
  reserved/delayed jobs migrate back to the ready list, so a crashed worker's
227
- in-flight job is re-reserved. Options: `?prefix=queues:` and `?retry_after=60`
228
- (or the `key_prefix` / `retry_after` constructor args).
317
+ in-flight job is re-reserved. Options: `?prefix=queues:` and `?retry_after=90`
318
+ (or the `key_prefix` / `retry_after` / `migration_batch_size` constructor args). A job whose body
319
+ does not decode, or whose `attempts` is not a number, is reserved raw, dead-lettered byte for byte
320
+ and acked, so it never lingers in `:reserved`. That holds when the body cannot be re-encoded
321
+ either (for example a number such as `1e400`, which real Redis' `cjson` refuses to serialise), and
322
+ it holds under `on_unknown_urn="delete"` too: such a message is dead-lettered, never deleted. Without
323
+ a dead-letter queue it is dropped, and the drop is logged as a warning. Dead-lettering a
324
+ reserved message is one atomic script (`ZREM` the reservation, then `RPUSH` the dead-letter
325
+ record and its notify entry); if the reservation expired first, nothing is written and a warning
326
+ says so, because the job was already migrated back and will be delivered again.
327
+
328
+ **Limit (A3):** the Laravel-compatible layout stores the bare envelope (Laravel's reserve script
329
+ has to decode it), so out-of-band transport headers such as the W3C `traceparent` cannot be
330
+ carried there. They are dropped without an error; the `trace_id` inside the envelope is still
331
+ preserved. The default (legacy) layout carries them.
229
332
 
230
333
  ## Framework adapters — Celery & Django
231
334
 
@@ -0,0 +1 @@
1
+ {"gaps": []}
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "1.21.1"
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"
@@ -31,6 +31,7 @@ from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
31
31
  from .routing import UnknownUrnStrategy
32
32
  from .transport import (
33
33
  BatchPopper,
34
+ DeadLetterWriter,
34
35
  HeaderPublisher,
35
36
  InMemoryTransport,
36
37
  ReceivedMessage,
@@ -41,7 +42,7 @@ from .transport import (
41
42
  Transport,
42
43
  )
43
44
 
44
- __version__ = "1.21.1"
45
+ __version__ = "1.23.0"
45
46
 
46
47
  __all__ = [
47
48
  "BabelQueue",
@@ -55,6 +56,7 @@ __all__ = [
55
56
  "InMemoryTransport",
56
57
  "ReceivedMessage",
57
58
  "BatchPopper",
59
+ "DeadLetterWriter",
58
60
  "HeaderPublisher",
59
61
  "Redeliverer",
60
62
  "Rewinder",
@@ -5,7 +5,7 @@ Go reference's ``babelqueue.Decode``, except that member names are case-sensitiv
5
5
 
6
6
  * the body must be exactly one JSON value (no BOM, trailing tokens, ``NaN`` / ``Infinity``, malformed
7
7
  numbers); a bare ``null`` decodes to an empty envelope, any other non-object root is rejected;
8
- * ``job``, ``trace_id``, ``meta.id|queue|lang`` and ``dead_letter.reason|error|exception|original_queue|lang``
8
+ * ``trace_id``, ``meta.id|queue|lang`` and ``dead_letter.reason|error|exception|original_queue|lang``
9
9
  are string-or-null;
10
10
  * ``attempts``, ``meta.schema_version|created_at`` and ``dead_letter.failed_at|attempts`` are
11
11
  integer-or-null: an integer literal (no fraction or exponent) that fits ``int64``;
@@ -14,6 +14,8 @@ Go reference's ``babelqueue.Decode``, except that member names are case-sensitiv
14
14
  * member names are matched exactly as spelled (case-sensitive, as the contract schema and the core
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
+ 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, and ``identity_verdict``, v1.7.0).
17
19
 
18
20
  A well-typed but non-conformant envelope is not poison: Go's consume path never calls ``Accepts``.
19
21
  """
@@ -64,7 +66,6 @@ _DEAD_LETTER = {
64
66
  "attempts": _INTEGER,
65
67
  }
66
68
  _TOP = {
67
- "job": _STRING,
68
69
  "trace_id": _STRING,
69
70
  "attempts": _INTEGER,
70
71
  "data": _DATA,