babelqueue 1.21.0__tar.gz → 1.22.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 (151) hide show
  1. {babelqueue-1.21.0 → babelqueue-1.22.0}/.github/workflows/ci.yml +18 -3
  2. {babelqueue-1.21.0 → babelqueue-1.22.0}/.github/workflows/release.yml +17 -2
  3. {babelqueue-1.21.0 → babelqueue-1.22.0}/CHANGELOG.md +107 -0
  4. {babelqueue-1.21.0 → babelqueue-1.22.0}/PKG-INFO +88 -7
  5. {babelqueue-1.21.0 → babelqueue-1.22.0}/README.md +87 -6
  6. babelqueue-1.22.0/conformance-known-gaps.json +1 -0
  7. {babelqueue-1.21.0 → babelqueue-1.22.0}/pyproject.toml +1 -1
  8. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/__init__.py +3 -1
  9. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/_envelope_shape.py +3 -2
  10. babelqueue-1.22.0/src/babelqueue/_poison_record.py +92 -0
  11. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/app.py +204 -19
  12. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/asb_transport.py +34 -1
  13. babelqueue-1.22.0/src/babelqueue/kafka_retry.py +336 -0
  14. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/kafka_transport.py +187 -56
  15. babelqueue-1.22.0/src/babelqueue/redis_transport.py +661 -0
  16. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/sqs_transport.py +30 -3
  17. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/transport.py +19 -2
  18. babelqueue-1.22.0/tests/conformance/CONFORMANCE_VERSION +1 -0
  19. babelqueue-1.22.0/tests/conformance/manifest.json +5802 -0
  20. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/schema/results.example.json +1 -1
  21. babelqueue-1.22.0/tests/redis_backend.py +76 -0
  22. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_app.py +63 -0
  23. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_asb_transport.py +13 -0
  24. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_behaviour_conformance.py +450 -133
  25. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_conformance.py +12 -4
  26. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_kafka_integration.py +29 -6
  27. babelqueue-1.22.0/tests/test_kafka_retry.py +389 -0
  28. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_kafka_transport.py +191 -57
  29. babelqueue-1.22.0/tests/test_redis_transport.py +1152 -0
  30. babelqueue-1.21.0/conformance-known-gaps.json +0 -84
  31. babelqueue-1.21.0/src/babelqueue/redis_transport.py +0 -355
  32. babelqueue-1.21.0/tests/conformance/CONFORMANCE_VERSION +0 -1
  33. babelqueue-1.21.0/tests/conformance/manifest.json +0 -1863
  34. babelqueue-1.21.0/tests/poison-trigger-set.json +0 -392
  35. babelqueue-1.21.0/tests/test_redis_transport.py +0 -504
  36. {babelqueue-1.21.0 → babelqueue-1.22.0}/.github/FUNDING.yml +0 -0
  37. {babelqueue-1.21.0 → babelqueue-1.22.0}/.github/dependabot.yml +0 -0
  38. {babelqueue-1.21.0 → babelqueue-1.22.0}/.gitignore +0 -0
  39. {babelqueue-1.21.0 → babelqueue-1.22.0}/LICENSE +0 -0
  40. {babelqueue-1.21.0 → babelqueue-1.22.0}/scripts/check_conformance_results.py +0 -0
  41. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/artemis_transport.py +0 -0
  42. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/celery.py +0 -0
  43. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/codec.py +0 -0
  44. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/contracts.py +0 -0
  45. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/dead_letter.py +0 -0
  46. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/django/__init__.py +0 -0
  47. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/django/apps.py +0 -0
  48. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/django/management/__init__.py +0 -0
  49. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  50. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  51. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/exceptions.py +0 -0
  52. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/gdpr.py +0 -0
  53. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/headers.py +0 -0
  54. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/idempotency.py +0 -0
  55. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/otel.py +0 -0
  56. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/outbox.py +0 -0
  57. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/pika_transport.py +0 -0
  58. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/pulsar_transport.py +0 -0
  59. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/py.typed +0 -0
  60. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/redrive.py +0 -0
  61. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/replay.py +0 -0
  62. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/routing.py +0 -0
  63. {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/schema.py +0 -0
  64. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/blank-ascii-job-urn-fallback.json +0 -0
  65. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
  66. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/dead-letter-roundtrip.json +0 -0
  67. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  68. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/duplicate-keys-last-wins.json +0 -0
  69. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
  70. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/forbidden-key-job-and-urn.json +0 -0
  71. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/forbidden-key-job-nonascii-space-and-urn.json +0 -0
  72. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
  73. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
  74. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
  75. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
  76. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
  77. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  78. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  79. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/invalid-wrong-case-job.json +0 -0
  80. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-array-urn-fallback.json +0 -0
  81. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-bool-urn-fallback.json +0 -0
  82. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-empty-string-urn-fallback.json +0 -0
  83. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-missing-job-array-urn.json +0 -0
  84. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-null-no-urn.json +0 -0
  85. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-null-urn-fallback.json +0 -0
  86. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-number-blank-urn.json +0 -0
  87. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-number-nonstring-urn.json +0 -0
  88. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-number-urn-fallback.json +0 -0
  89. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-object-urn-fallback.json +0 -0
  90. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/meta-ext-vendor-roundtrip.json +0 -0
  91. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/order-created.json +0 -0
  92. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-job-cr.json +0 -0
  93. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-job-ff.json +0 -0
  94. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-job-lf.json +0 -0
  95. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-job-space.json +0 -0
  96. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-job-tab.json +0 -0
  97. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-job-vt.json +0 -0
  98. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-trace-id-cr.json +0 -0
  99. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-trace-id-ff.json +0 -0
  100. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-trace-id-lf.json +0 -0
  101. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-trace-id-space.json +0 -0
  102. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-trace-id-tab.json +0 -0
  103. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-trace-id-vt.json +0 -0
  104. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-job-and-urn-both-blank.json +0 -0
  105. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-reemit-job-ascii-padded.json +0 -0
  106. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-reemit-job-unicode-padded.json +0 -0
  107. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-reemit-trace-id-padded.json +0 -0
  108. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-unicode-job-emsp.json +0 -0
  109. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-unicode-job-ideographic.json +0 -0
  110. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-unicode-job-nbsp.json +0 -0
  111. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-urn-only-ascii-blank.json +0 -0
  112. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-urn-only-unicode-space.json +0 -0
  113. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  114. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
  115. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
  116. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
  117. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  118. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/urn-only-roundtrip.json +0 -0
  119. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/wrong-case-keys-roundtrip.json +0 -0
  120. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  121. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/schema/results.schema.json +0 -0
  122. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/fakes/confluent_kafka/__init__.py +0 -0
  123. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/fakes/confluent_kafka/admin.py +0 -0
  124. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/fixtures/dead-lettered.json +0 -0
  125. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/fixtures/order-created.json +0 -0
  126. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_artemis_conformance.py +0 -0
  127. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_artemis_transport.py +0 -0
  128. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_asb_conformance.py +0 -0
  129. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_celery.py +0 -0
  130. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_codec.py +0 -0
  131. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_dead_letter.py +0 -0
  132. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_django.py +0 -0
  133. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_gdpr.py +0 -0
  134. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_headers.py +0 -0
  135. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_idempotency.py +0 -0
  136. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_kafka_conformance.py +0 -0
  137. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_kafka_integration_logic.py +0 -0
  138. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_otel.py +0 -0
  139. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_outbox.py +0 -0
  140. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_overhead.py +0 -0
  141. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_pika_transport.py +0 -0
  142. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_pulsar_conformance.py +0 -0
  143. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_pulsar_transport.py +0 -0
  144. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_redrive.py +0 -0
  145. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_reencode_regression.py +0 -0
  146. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_replay.py +0 -0
  147. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_schema.py +0 -0
  148. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_schema_conformance.py +0 -0
  149. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_shutdown.py +0 -0
  150. {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_sqs_conformance.py +0 -0
  151. {babelqueue-1.21.0 → babelqueue-1.22.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.6.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.6.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.6.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,113 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.22.0] - 2026-10-24
13
+
14
+ Implements ADR-0033 (Redis) and ADR-0034 (Kafka tiers, backoff, poison). The envelope stays at
15
+ `schema_version: 1`; the vendored conformance suite moves to v1.6.0 and
16
+ `conformance-known-gaps.json` is now empty.
17
+
18
+ ### Changed
19
+ - 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.
20
+ - **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).
21
+ - **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.
22
+
23
+ ### Fixed
24
+ - **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.
25
+ - **Redis, Laravel-compatible layout: a poison reservation is dead-lettered in every
26
+ configuration.** The DLQ write no longer depends on `dead_letter_enabled` (default `False`) or
27
+ `on_unknown_urn`: an undecodable body or one without a numeric `attempts` is written raw to
28
+ `<queue>.dlq` and acked instead of being logged and dropped (broker-bindings section 1.1,
29
+ ADR-0033; same as the Go reference). Other version-gated routes keep their previous behaviour.
30
+ - **Redis, Laravel-compatible layout: a job with a non-numeric `attempts` or an undecodable body no
31
+ longer aborts the reserve script.** It is reserved raw (`pcall` guard), dead-lettered byte for
32
+ byte and acked, leaving nothing in `:reserved` (ADR-0033 (c)).
33
+ - **Redis, legacy layout: a `kill -9` no longer strands a message in `<queue>:processing`.** Every
34
+ reservation writes a `<token>:<raw>` member into `<queue>:processing:deadlines`; a sweep before
35
+ each reserve moves lapsed reservations back to the end of `<queue>`. Ack, release and dead-letter
36
+ are gated on their own reservation, so a late ack never touches a newer one. The legacy mode
37
+ remains the default and the `<queue>:processing` key name is unchanged.
38
+ - **Kafka without retry tiers: a failing handler no longer stops the worker** (Go
39
+ `leaveRepublishUnsettled` parity). The transport still raises `BabelQueueError` (section 6.5), but
40
+ the runtime now catches a failed retry release, logs it (`babelqueue.app`, ERROR, "NOT
41
+ acknowledged"), leaves the record unsettled (no commit; a rewind-capable transport seeks back and
42
+ the loop pauses `_rewind_pause` first) and keeps consuming. Previously the error escaped
43
+ `consume()`. Applies to any transport whose retry release fails.
44
+ - **Kafka poison follows Go `routePoison` when no DLQ is configured.** With the DLQ on
45
+ (`dead_letter=True` or `on_unknown_urn=DEAD_LETTER`) the wrapped record is written to
46
+ `<topic>.dlq` (`dead_letter.reason: "poison"`) and then committed. With the DLQ off the poison
47
+ record is **not** written anywhere: `on_unknown_urn=DELETE` drops it, every other strategy takes
48
+ the bounded FAIL path (retried through the retry tiers up to `max_attempts` with a bare
49
+ `{"attempts": N}` body, then dropped; without tiers the record is redelivered in place).
50
+ Operational note: when the DLQ is on, `<topic>.dlq` must exist; a failed write leaves the record
51
+ uncommitted and rewound (it is never dropped).
52
+ - **Unknown-URN `RELEASE` and the retry release leave the delivery unsettled on failure** (Go
53
+ `leaveRepublishUnsettled`): a failed re-publish of an unknown-URN message is no longer counted as
54
+ a handler failure and can no longer lead to the record being acked and lost.
55
+ - **Redis, legacy `:processing` layout: an undecodable body is dead-lettered on its first
56
+ delivery.** A body Go's typed `Decode` rejects (neither a JSON object nor `null`, or wrong member types such as
57
+ a string `attempts`/`meta`; same criterion as Kafka poison) is written verbatim to `<queue>.dlq`
58
+ through the gated settle script and removed from `:processing`, in every configuration, instead of
59
+ being retried until `max_attempts`. A valid JSON object without an identity keeps the existing
60
+ bounded path.
61
+ - **Kafka: a record without a valid `job` / `urn` is poison** (ADR-0034 section 10), not an
62
+ unknown URN: with the DLQ on it is dead-lettered wrapped with `dead_letter.reason: "poison"` and
63
+ never retried; with the DLQ off it follows Go `routePoison` (see above).
64
+
65
+ - **Redis, Laravel-compatible layout: the reserve script no longer aborts on a body that decodes
66
+ but cannot be re-encoded** (`cjson.encode` of `inf`/`NaN`, e.g. `1e400`, raised after the `LPOP`
67
+ and lost the job). The encode is now inside `pcall`; the job is reserved raw and takes the poison
68
+ path. Note: fakeredis' `cjson` serialises `inf` as `Infinity` and masks this; it is proven by a
69
+ real-Redis test (CI `redis:7`) and by a Lua test against a failing `cjson` stub.
70
+ - **`on_unknown_urn="delete"` no longer discards a Redis poison reservation.** It is dead-lettered
71
+ byte for byte like the Kafka poison record already was; a genuine unknown URN is still deleted.
72
+ - **Redis, Laravel-compatible layout: `dead_letter` is atomic and gated.** One script `ZREM`s the
73
+ reservation and writes the dead-letter record and its notify entry; an expired reservation writes
74
+ nothing (and logs a warning) instead of publishing a duplicate.
75
+ - **A message dropped because it is undeliverable and no dead-letter queue is configured is
76
+ logged** (`babelqueue.app`, WARNING) instead of disappearing silently.
77
+ - **Kafka `RetryReinjector` no longer loses records:** it kept one held record per partition, so a
78
+ second record on the same partition overwrote the first. It now keeps all of them in order.
79
+ - **A worker on a tier-less Kafka transport with `max_attempts` > 1 warns at start-up** that a
80
+ failing handler cannot be retried: the record is left uncommitted and redelivered.
81
+ - Internal: the legacy `:processing` settle script takes `[token, raw]` and builds the deadline
82
+ member itself.
83
+
84
+ ### Added
85
+ - `RedisTransport(retry_after=90, migration_batch_size=-1)` (also `?retry_after=` in the URL;
86
+ `None` / `null` disables recovery). The Laravel-compatible `retry_after` default is now 90 s
87
+ (was 60 s).
88
+ - SQS: `SqsTransport.publish(..., delay_seconds=)` (`DelaySeconds` 0..900; out of range or FIFO
89
+ raises `BabelQueueError`).
90
+ - Kafka: `KafkaTransport(retry_tiers_ms=...)` with `DEFAULT_RETRY_TIERS_MS`, `retry_topic()` and
91
+ the `babelqueue.kafka_retry.RetryReinjector` consumer that moves parked records from
92
+ `<topic>.retry.<n>` back to the work topic (broker-bindings section 6.4). Opt-in: without tiers a
93
+ retry / `publish_delayed` raises `BabelQueueError` (the transport raises; the worker logs it and
94
+ leaves the record uncommitted, see Fixed).
95
+ - Worker-side retry backoff: `BabelQueue(retry_backoff=, retry_backoff_multiplier=,
96
+ retry_backoff_max=, retry_jitter=)` (ADR-0034 section 9); the defaults keep an immediate release.
97
+ - Conformance runner covers the v1.6.0 sections (`redis_layout`, `redis_delay`, `retry_backoff`,
98
+ `kafka_poison`, ...). CI runs the Redis cases against a real `redis:7` with `BQ_REQUIRE_REDIS=1`.
99
+
100
+ ### Changed
101
+ - **Kafka retries go to the retry tiers.** With `retry_tiers_ms` set a failed message is parked on
102
+ `<queue>.retry.<n>` and needs a running `RetryReinjector`; the retry topics must exist. Without
103
+ tiers a retry now raises from the transport instead of republishing onto the work topic; the
104
+ runtime logs it and redelivers the record rather than stopping the worker.
105
+
106
+ ### Documentation
107
+ - README: Redis layouts, the limit that the Laravel-compatible layout carries no out-of-band headers (ADR-0033 §5),
108
+ Kafka retry tiers, `LogAppendTime` on the tier topics, retry backoff, SQS `delay_seconds`.
109
+
110
+ ## [1.21.1] - 2026-10-20
111
+
112
+ ### Fixed
113
+ - **Azure Service Bus: `publish` no longer fails with `Value supplied for int invalid`.** The pure-Python AMQP
114
+ stack in `azure-servicebus` encodes a bare Python int as AMQP int32, so the millisecond `bq-created-at`
115
+ application property (broker-bindings.md §4.3) overflowed at send time. Out-of-int32 integer properties
116
+ are now sent as AMQP `long`, matching the Go, Java, .NET and Node transports. The native projection and
117
+ the envelope body are unchanged.
118
+
12
119
  ## [1.21.0] - 2026-10-19
13
120
 
14
121
  Implements ADR-0032 (conformance packaging, results format, ASCII trim and the non-string `job` rule).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: babelqueue
3
- Version: 1.21.0
3
+ Version: 1.22.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,76 @@ 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 tiers the record is redelivered in place, as above). On
317
+ the Redis legacy (`<queue>:processing`) layout a body that Go's typed decode rejects (neither a JSON
318
+ object nor `null`, or wrong member types) is dead-lettered raw on its first
319
+ delivery and removed from `:processing`.
320
+
260
321
  ### Sharing a Redis queue with Laravel
261
322
 
262
323
  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:
324
+ `BLMOVE` into a `<queue>:processing` list to reserve; `LREM` on ack). Each reservation also
325
+ writes a `<token>:<raw>` member (32 lowercase hex characters of token) into the
326
+ `<queue>:processing:deadlines` sorted set, and before every reserve a sweep moves the lapsed
327
+ reservations back to the end of the list, so a `kill -9` no longer strands a message in
328
+ `:processing`; a late ack of a swept reservation touches nothing of the newer one. `retry_after`
329
+ (default **90** seconds; `None` / `?retry_after=null` disables recovery) sets that deadline, and
330
+ `migration_batch_size` (`-1` = all, otherwise `>= 1`) bounds how many due members one sweep or
331
+ migration moves. To consume a **shared** Laravel BabelQueue Redis queue instead, enable
332
+ Laravel-compatible mode:
265
333
 
266
334
  ```python
267
335
  app = BabelQueue("redis://localhost:6379/0?laravel=1", queue="orders")
@@ -278,8 +346,21 @@ member a Python worker writes is identical to a Laravel worker's — either side
278
346
  ack (`ZREM`) the other's reservation, so a Python worker and a Laravel worker share
279
347
  one queue without losing or double-processing messages. Before each pop, expired
280
348
  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).
349
+ in-flight job is re-reserved. Options: `?prefix=queues:` and `?retry_after=90`
350
+ (or the `key_prefix` / `retry_after` / `migration_batch_size` constructor args). A job whose body
351
+ does not decode, or whose `attempts` is not a number, is reserved raw, dead-lettered byte for byte
352
+ and acked, so it never lingers in `:reserved`. That holds when the body cannot be re-encoded
353
+ either (for example a number such as `1e400`, which real Redis' `cjson` refuses to serialise), and
354
+ it holds under `on_unknown_urn="delete"` too: such a message is dead-lettered, never deleted. Without
355
+ a dead-letter queue it is dropped, and the drop is logged as a warning. Dead-lettering a
356
+ reserved message is one atomic script (`ZREM` the reservation, then `RPUSH` the dead-letter
357
+ record and its notify entry); if the reservation expired first, nothing is written and a warning
358
+ says so, because the job was already migrated back and will be delivered again.
359
+
360
+ **Limit (A3):** the Laravel-compatible layout stores the bare envelope (Laravel's reserve script
361
+ has to decode it), so out-of-band transport headers such as the W3C `traceparent` cannot be
362
+ carried there. They are dropped without an error; the `trace_id` inside the envelope is still
363
+ preserved. The default (legacy) layout carries them.
283
364
 
284
365
  ## Framework adapters — Celery & Django
285
366
 
@@ -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,76 @@ 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 tiers the record is redelivered in place, as above). On
263
+ the Redis legacy (`<queue>:processing`) layout a body that Go's typed decode rejects (neither a JSON
264
+ object nor `null`, or wrong member types) is dead-lettered raw on its first
265
+ delivery and removed from `:processing`.
266
+
206
267
  ### Sharing a Redis queue with Laravel
207
268
 
208
269
  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:
270
+ `BLMOVE` into a `<queue>:processing` list to reserve; `LREM` on ack). Each reservation also
271
+ writes a `<token>:<raw>` member (32 lowercase hex characters of token) into the
272
+ `<queue>:processing:deadlines` sorted set, and before every reserve a sweep moves the lapsed
273
+ reservations back to the end of the list, so a `kill -9` no longer strands a message in
274
+ `:processing`; a late ack of a swept reservation touches nothing of the newer one. `retry_after`
275
+ (default **90** seconds; `None` / `?retry_after=null` disables recovery) sets that deadline, and
276
+ `migration_batch_size` (`-1` = all, otherwise `>= 1`) bounds how many due members one sweep or
277
+ migration moves. To consume a **shared** Laravel BabelQueue Redis queue instead, enable
278
+ Laravel-compatible mode:
211
279
 
212
280
  ```python
213
281
  app = BabelQueue("redis://localhost:6379/0?laravel=1", queue="orders")
@@ -224,8 +292,21 @@ member a Python worker writes is identical to a Laravel worker's — either side
224
292
  ack (`ZREM`) the other's reservation, so a Python worker and a Laravel worker share
225
293
  one queue without losing or double-processing messages. Before each pop, expired
226
294
  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).
295
+ in-flight job is re-reserved. Options: `?prefix=queues:` and `?retry_after=90`
296
+ (or the `key_prefix` / `retry_after` / `migration_batch_size` constructor args). A job whose body
297
+ does not decode, or whose `attempts` is not a number, is reserved raw, dead-lettered byte for byte
298
+ and acked, so it never lingers in `:reserved`. That holds when the body cannot be re-encoded
299
+ either (for example a number such as `1e400`, which real Redis' `cjson` refuses to serialise), and
300
+ it holds under `on_unknown_urn="delete"` too: such a message is dead-lettered, never deleted. Without
301
+ a dead-letter queue it is dropped, and the drop is logged as a warning. Dead-lettering a
302
+ reserved message is one atomic script (`ZREM` the reservation, then `RPUSH` the dead-letter
303
+ record and its notify entry); if the reservation expired first, nothing is written and a warning
304
+ says so, because the job was already migrated back and will be delivered again.
305
+
306
+ **Limit (A3):** the Laravel-compatible layout stores the bare envelope (Laravel's reserve script
307
+ has to decode it), so out-of-band transport headers such as the W3C `traceparent` cannot be
308
+ carried there. They are dropped without an error; the `trace_id` inside the envelope is still
309
+ preserved. The default (legacy) layout carries them.
229
310
 
230
311
  ## Framework adapters — Celery & Django
231
312
 
@@ -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.0"
7
+ version = "1.22.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.0"
45
+ __version__ = "1.22.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).
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,
@@ -0,0 +1,92 @@
1
+ """The DLQ record of a poison body (ADR-0035, error-handling.md section 6).
2
+
3
+ A body that is a strict RFC 8259 JSON object (valid UTF-8, no ``NaN`` / ``Infinity`` literal, no
4
+ unpaired surrogate escape, no top-level ``dead_letter`` member) is *spliced* at byte level: every
5
+ original character is kept and only a ``dead_letter`` member is appended, so no value is re-encoded
6
+ (``1e400`` and a 64-bit integer stay as written, a repeated member name is not collapsed). Anything
7
+ else is wrapped as ``{"raw": <string>, "raw_encoding"?: "base64", "dead_letter": ...}``: ``raw`` is
8
+ the body as plain text when it is valid UTF-8, otherwise the standard base64 of the exact bytes with
9
+ ``"raw_encoding": "base64"``. U+FFFD substitution never happens.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import json
16
+ import re
17
+ from typing import Any, Dict, List, Optional, Tuple
18
+
19
+ _JSON_WHITESPACE = " \t\r\n"
20
+ _STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"', re.DOTALL)
21
+ _ESCAPE = re.compile(r"\\(u[0-9a-fA-F]{4}|.)", re.DOTALL)
22
+
23
+
24
+ def _reject_constant(name: str) -> Any:
25
+ raise ValueError("invalid JSON constant " + name)
26
+
27
+
28
+ def _has_unpaired_surrogate_escape(text: str) -> bool:
29
+ """True when a string literal of the (already syntax-valid) JSON ``text`` holds a ``\\uD800``-style
30
+ escape that is not half of a high+low pair."""
31
+ for literal in _STRING_LITERAL.finditer(text):
32
+ pending_high = False
33
+ for escape in _ESCAPE.finditer(literal.group(0)):
34
+ token = escape.group(1)
35
+ code = int(token[1:], 16) if len(token) == 5 and token[0] == "u" else -1
36
+ is_high = 0xD800 <= code <= 0xDBFF
37
+ is_low = 0xDC00 <= code <= 0xDFFF
38
+ if pending_high and not is_low:
39
+ return True
40
+ if is_low and not pending_high:
41
+ return True
42
+ pending_high = is_high
43
+ if pending_high:
44
+ return True
45
+ return False
46
+
47
+
48
+ def _is_spliceable_object(text: str) -> bool:
49
+ """Whether ``text`` is one strict JSON object that can be spliced (ADR-0035 section 1)."""
50
+ last_pairs: List[Optional[List[Tuple[str, Any]]]] = [None]
51
+
52
+ def hook(pairs: List[Tuple[str, Any]]) -> Dict[str, Any]:
53
+ last_pairs[0] = pairs # the outermost object is the last one the decoder completes
54
+ return {}
55
+
56
+ try:
57
+ parsed = json.loads(text, parse_constant=_reject_constant, object_pairs_hook=hook)
58
+ except (ValueError, RecursionError):
59
+ return False
60
+ if not isinstance(parsed, dict) or last_pairs[0] is None:
61
+ return False
62
+ if any(key == "dead_letter" for key, _ in last_pairs[0]):
63
+ return False
64
+ return not _has_unpaired_surrogate_escape(text)
65
+
66
+
67
+ def build_poison_record(text: str, dead_letter_block: Dict[str, Any]) -> str:
68
+ """The DLQ record body for the poison body ``text``.
69
+
70
+ ``text`` is the body as the transport carries it: bytes that were not UTF-8 appear as
71
+ ``surrogateescape`` code points. ``dead_letter_block`` is the ``dead_letter`` member's value."""
72
+ try:
73
+ data = text.encode("utf-8", "surrogateescape")
74
+ except UnicodeEncodeError:
75
+ data = text.encode("utf-8", "surrogatepass")
76
+ try:
77
+ decoded: Optional[str] = data.decode("utf-8")
78
+ except UnicodeDecodeError:
79
+ decoded = None
80
+
81
+ block = json.dumps(dead_letter_block, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
82
+ if decoded is not None and _is_spliceable_object(decoded):
83
+ trimmed = decoded.strip(_JSON_WHITESPACE)
84
+ if trimmed[1:-1].strip(_JSON_WHITESPACE) == "":
85
+ return '{"dead_letter":' + block + "}"
86
+ return trimmed[:-1] + ',"dead_letter":' + block + "}"
87
+
88
+ if decoded is not None:
89
+ raw = json.dumps(decoded, ensure_ascii=False)
90
+ return '{"raw":' + raw + ',"dead_letter":' + block + "}"
91
+ encoded = base64.b64encode(data).decode("ascii")
92
+ return '{"raw":"' + encoded + '","raw_encoding":"base64","dead_letter":' + block + "}"