babelqueue 1.24.0__tar.gz → 1.25.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 (153) hide show
  1. {babelqueue-1.24.0 → babelqueue-1.25.0}/.github/workflows/ci.yml +1 -1
  2. {babelqueue-1.24.0 → babelqueue-1.25.0}/.github/workflows/release.yml +1 -1
  3. {babelqueue-1.24.0 → babelqueue-1.25.0}/CHANGELOG.md +87 -0
  4. {babelqueue-1.24.0 → babelqueue-1.25.0}/PKG-INFO +1 -1
  5. {babelqueue-1.24.0 → babelqueue-1.25.0}/pyproject.toml +1 -1
  6. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/__init__.py +7 -1
  7. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/app.py +145 -24
  8. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/artemis_transport.py +26 -4
  9. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/asb_transport.py +42 -5
  10. babelqueue-1.25.0/src/babelqueue/dead_letter.py +87 -0
  11. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/headers.py +71 -1
  12. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/kafka_transport.py +68 -2
  13. babelqueue-1.25.0/src/babelqueue/otel.py +677 -0
  14. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/pulsar_transport.py +31 -0
  15. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/redrive.py +11 -3
  16. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/transport.py +24 -0
  17. babelqueue-1.25.0/tests/conformance/CONFORMANCE_VERSION +1 -0
  18. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/manifest.json +2335 -2
  19. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/schema/results.example.json +25 -1
  20. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_artemis_transport.py +68 -0
  21. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_asb_transport.py +135 -0
  22. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_behaviour_conformance.py +2 -2
  23. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_conformance.py +3 -0
  24. babelqueue-1.25.0/tests/test_dead_letter_observer.py +298 -0
  25. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_headers.py +18 -0
  26. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_kafka_retry.py +58 -0
  27. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_kafka_transport.py +140 -0
  28. babelqueue-1.25.0/tests/test_otel.py +553 -0
  29. babelqueue-1.25.0/tests/test_otel_conformance.py +480 -0
  30. babelqueue-1.25.0/tests/test_publish_envelope.py +181 -0
  31. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_pulsar_transport.py +101 -0
  32. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_redrive.py +8 -0
  33. babelqueue-1.24.0/src/babelqueue/dead_letter.py +0 -39
  34. babelqueue-1.24.0/src/babelqueue/otel.py +0 -265
  35. babelqueue-1.24.0/tests/conformance/CONFORMANCE_VERSION +0 -1
  36. babelqueue-1.24.0/tests/test_otel.py +0 -263
  37. {babelqueue-1.24.0 → babelqueue-1.25.0}/.github/FUNDING.yml +0 -0
  38. {babelqueue-1.24.0 → babelqueue-1.25.0}/.github/dependabot.yml +0 -0
  39. {babelqueue-1.24.0 → babelqueue-1.25.0}/.gitignore +0 -0
  40. {babelqueue-1.24.0 → babelqueue-1.25.0}/LICENSE +0 -0
  41. {babelqueue-1.24.0 → babelqueue-1.25.0}/README.md +0 -0
  42. {babelqueue-1.24.0 → babelqueue-1.25.0}/conformance-known-gaps.json +0 -0
  43. {babelqueue-1.24.0 → babelqueue-1.25.0}/scripts/check_conformance_results.py +0 -0
  44. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/_envelope_shape.py +0 -0
  45. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/_poison_record.py +0 -0
  46. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/celery.py +0 -0
  47. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/codec.py +0 -0
  48. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/contracts.py +0 -0
  49. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/django/__init__.py +0 -0
  50. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/django/apps.py +0 -0
  51. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/django/management/__init__.py +0 -0
  52. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  53. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  54. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/exceptions.py +0 -0
  55. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/gdpr.py +0 -0
  56. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/idempotency.py +0 -0
  57. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/kafka_retry.py +0 -0
  58. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/outbox.py +0 -0
  59. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/pika_transport.py +0 -0
  60. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/py.typed +0 -0
  61. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/redis_transport.py +0 -0
  62. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/replay.py +0 -0
  63. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/routing.py +0 -0
  64. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/schema.py +0 -0
  65. {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/sqs_transport.py +0 -0
  66. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/blank-ascii-job-urn-fallback.json +0 -0
  67. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
  68. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/dead-letter-roundtrip.json +0 -0
  69. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  70. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/duplicate-keys-last-wins.json +0 -0
  71. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
  72. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/forbidden-key-job-and-urn.json +0 -0
  73. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/forbidden-key-job-nonascii-space-and-urn.json +0 -0
  74. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
  75. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
  76. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
  77. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
  78. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
  79. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  80. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  81. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/invalid-wrong-case-job.json +0 -0
  82. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-array-urn-fallback.json +0 -0
  83. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-bool-urn-fallback.json +0 -0
  84. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-empty-string-urn-fallback.json +0 -0
  85. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-missing-job-array-urn.json +0 -0
  86. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-null-no-urn.json +0 -0
  87. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-null-urn-fallback.json +0 -0
  88. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-number-blank-urn.json +0 -0
  89. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-number-nonstring-urn.json +0 -0
  90. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-number-urn-fallback.json +0 -0
  91. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-object-urn-fallback.json +0 -0
  92. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/meta-ext-vendor-roundtrip.json +0 -0
  93. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/order-created.json +0 -0
  94. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-job-cr.json +0 -0
  95. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-job-ff.json +0 -0
  96. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-job-lf.json +0 -0
  97. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-job-space.json +0 -0
  98. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-job-tab.json +0 -0
  99. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-job-vt.json +0 -0
  100. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-trace-id-cr.json +0 -0
  101. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-trace-id-ff.json +0 -0
  102. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-trace-id-lf.json +0 -0
  103. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-trace-id-space.json +0 -0
  104. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-trace-id-tab.json +0 -0
  105. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-trace-id-vt.json +0 -0
  106. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-job-and-urn-both-blank.json +0 -0
  107. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-reemit-job-ascii-padded.json +0 -0
  108. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-reemit-job-unicode-padded.json +0 -0
  109. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-reemit-trace-id-padded.json +0 -0
  110. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-unicode-job-emsp.json +0 -0
  111. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-unicode-job-ideographic.json +0 -0
  112. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-unicode-job-nbsp.json +0 -0
  113. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-urn-only-ascii-blank.json +0 -0
  114. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-urn-only-unicode-space.json +0 -0
  115. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  116. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
  117. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
  118. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
  119. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  120. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/urn-only-roundtrip.json +0 -0
  121. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/wrong-case-keys-roundtrip.json +0 -0
  122. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  123. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/schema/results.schema.json +0 -0
  124. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/fakes/confluent_kafka/__init__.py +0 -0
  125. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/fakes/confluent_kafka/admin.py +0 -0
  126. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/fixtures/dead-lettered.json +0 -0
  127. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/fixtures/order-created.json +0 -0
  128. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/redis_backend.py +0 -0
  129. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_app.py +0 -0
  130. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_artemis_conformance.py +0 -0
  131. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_asb_conformance.py +0 -0
  132. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_celery.py +0 -0
  133. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_codec.py +0 -0
  134. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_dead_letter.py +0 -0
  135. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_django.py +0 -0
  136. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_gdpr.py +0 -0
  137. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_idempotency.py +0 -0
  138. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_kafka_conformance.py +0 -0
  139. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_kafka_integration.py +0 -0
  140. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_kafka_integration_logic.py +0 -0
  141. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_outbox.py +0 -0
  142. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_overhead.py +0 -0
  143. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_pika_transport.py +0 -0
  144. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_poison_routing.py +0 -0
  145. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_pulsar_conformance.py +0 -0
  146. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_redis_transport.py +0 -0
  147. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_reencode_regression.py +0 -0
  148. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_replay.py +0 -0
  149. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_schema.py +0 -0
  150. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_schema_conformance.py +0 -0
  151. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_shutdown.py +0 -0
  152. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_sqs_conformance.py +0 -0
  153. {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_sqs_transport.py +0 -0
@@ -177,7 +177,7 @@ jobs:
177
177
  --health-retries 10
178
178
  env:
179
179
  # The pinned conformance release (ADR-0032). Bump together with tests/conformance/.
180
- BQ_CONFORMANCE_VERSION: 1.8.0
180
+ BQ_CONFORMANCE_VERSION: 1.9.0
181
181
  # sha256 of that release's fetch.sh: the script runs on the runner, so it is pinned rather
182
182
  # than trusted from a mutable tag. Bump together with BQ_CONFORMANCE_VERSION.
183
183
  BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849
@@ -28,7 +28,7 @@ jobs:
28
28
  --health-retries 10
29
29
  env:
30
30
  # The pinned conformance release (ADR-0032). Keep identical to ci.yml.
31
- BQ_CONFORMANCE_VERSION: 1.8.0
31
+ BQ_CONFORMANCE_VERSION: 1.9.0
32
32
  BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849
33
33
  permissions:
34
34
  id-token: write # PyPI Trusted Publishing (OIDC) — no API token needed
@@ -9,6 +9,93 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.25.0] - 2026-11-01
13
+
14
+ ### Changed (dashboard-breaking) — `babelqueue.otel` follows the OpenTelemetry messaging semantic conventions v1.44.0 (ADR-0038)
15
+ - **Span names change with no overlap:** `publish <urn>` becomes `send <queue>` and
16
+ `process <urn>` becomes `process <queue>` (the bare `send` / `process` when there is no queue).
17
+ The URN is never in the span name now (it is unbounded); it is the `babelqueue.urn` attribute.
18
+ The producer's `messaging.destination.name` is the queue the message is published to (it was the
19
+ URN), so a producer span and its consumer span share one destination. **Saved queries and
20
+ dashboards that filter on the old span names or on the producer destination must be updated.**
21
+ - **`messaging.system` is now the broker when you pass it.** `wrap_handler(..., system="kafka")` and
22
+ `publish(..., system="kafka")` (`redis`, `rabbitmq`, `aws_sqs`, `servicebus`, `pulsar`, `kafka`,
23
+ `activemq`; any other value is written as given). **Omitting `system` keeps `babelqueue`, which is
24
+ deprecated** (a one-time warning is logged); what an absent `system` means after the deprecation
25
+ window is not decided yet, so pass it now.
26
+ - New span attributes: `messaging.operation.type` / `messaging.operation.name` (`send` or
27
+ `process`), `babelqueue.urn`, `babelqueue.schema_version`, `babelqueue.attempts` and, on a failure,
28
+ `error.type` (`module.QualName` of the exception, the bare name for builtins) with an explicit
29
+ `ERROR` status whose description is the exception message. `consumer_group="..."` on
30
+ `wrap_handler` writes `messaging.consumer.group.name`.
31
+ - **Dual-emit for this MINOR only:** the deprecated `messaging.operation` (`publish` / `process`) and
32
+ `messaging.babelqueue.attempts` are still written next to their replacements. **The next MINOR
33
+ removes both.**
34
+ - `babelqueue.otel.SEMCONV_VERSION` (`1.44.0`) and `SEMCONV_SCHEMA_URL` (`https://opentelemetry.io/schemas/1.44.0`)
35
+ are public; create the tracer and meter with the schema URL
36
+ (`trace.get_tracer("orders", schema_url=otel.SEMCONV_SCHEMA_URL)`).
37
+ - A `trace_id` that is not a UUID is still preserved byte for byte and hashed for the OTel TraceID
38
+ (`trace_id_of` is unchanged); `wrap_handler` now logs a warning once per distinct value
39
+ (message-envelope.md 5.1, ADR-0038 section 7). Nothing is rejected or regenerated.
40
+
41
+ ### Added
42
+ - **Standard metrics (ADR-0039), opt-in.** Pass `meter=` to `wrap_handler` and `publish` to record
43
+ `messaging.process.duration` (histogram, `s`, semconv bucket advice where the API supports it),
44
+ `messaging.client.consumed.messages`, `messaging.client.sent.messages` and `babelqueue.retries`
45
+ (counters, `{message}`; `retries` counts a delivery with `attempts > 0`). The attributes are
46
+ `messaging.system`, `messaging.operation.name`, `messaging.destination.name` (when known) and
47
+ `error.type` (on failure); never the URN, an id, the attempt count or a dead-letter reason. With no
48
+ `meter` nothing is recorded.
49
+ - **`babelqueue.dlq.messages` and the dead-letter observer seam (ADR-0039 section 5).**
50
+ `BabelQueue(..., dead_letter_observer=callback)` calls `callback(DeadLetterEvent(queue,
51
+ dead_letter_queue, reason))` once per dead-letter the runtime carried out, after the DLQ write is
52
+ confirmed (`BabelQueue._write_dead_letter`, the one chokepoint for exhausted retries, an unknown
53
+ URN, a poison body and a version-gated raw body) or after an SDK-initiated native dead-letter. A
54
+ failed write, a native no-op (SQS) and a broker-side dead-letter are not reported; an exception
55
+ raised by the observer is logged and never changes how the message is settled.
56
+ `otel.dead_letter_observer(meter, system=...)` is the observer that counts the metric.
57
+ New exports: `DeadLetterEvent`, `DeadLetterObserver`, and the `NativeDeadLetterSettler` protocol.
58
+ - **`NativeDeadLetterSettler`**: the optional native-settle variant
59
+ `settle_native_dead_letter(message, reason, description) -> bool` next to `NativeSettler`
60
+ (unchanged). When a transport implements it the runtime prefers it and reports a dead-letter only
61
+ on `True`; `AsbTransport` implements it, the SQS no-op does not.
62
+ - **`BabelQueue.publish_envelope(urn, data, *, queue=None, trace_id=None, headers=None)`** returns a
63
+ `PublishResult(envelope, error)` pair instead of raising. After a transport failure it still
64
+ carries the envelope that was handed to the transport (so `otel.publish` can put `meta.id` and
65
+ `trace_id` on a failed span); after a `make` or `encode` failure `envelope` is `None`. `publish`
66
+ and `publish_with_headers` delegate to it and behave exactly as before (an empty `queue` still
67
+ resolves to the default queue). `otel.publish` falls back to a duck-typed `app.publish(...)` for
68
+ objects that do not have it.
69
+ - Conformance **1.9.0** is vendored; the `span-shape`, `trace-id` and `metrics` sections (and the six
70
+ new `traceparent` cases) run and pass.
71
+ - **W3C `traceparent` / `tracestate` on the Kafka, Pulsar, Artemis and Azure Service Bus
72
+ transports (ADR-0028; broker-bindings 4.3, 5.2, 6.3, 7.2).** Each transport now implements the
73
+ `HeaderPublisher` capability: `publish_with_headers(queue, body, headers)` carries the headers
74
+ out of band beside the `bq-*` / `bq_*` projection (Kafka record headers, Pulsar message
75
+ properties, Artemis AMQP 1.0 application-properties, Azure Service Bus application properties).
76
+ The projection wins a key collision, and the body is byte-identical to a plain `publish`.
77
+ `pop` surfaces every non-projection header on `ReceivedMessage.headers` (so
78
+ `babelqueue.otel.wrap_handler` links the consumer span to the producer's across these brokers);
79
+ the projection keys themselves are never leaked, and `bq-replay-bypass` (ADR-0027) passes
80
+ through. A Kafka retry parks the consumed record's `traceparent` / `tracestate` on the tier topic
81
+ (the re-inject consumer already keeps every header except `bq-delay` / `bq-original-topic`), so
82
+ a retried message stays in the original trace. Retry tier names, `retry_tiers_ms` and the
83
+ poison / DLQ shapes are unchanged. `babelqueue.headers.out_of_band_headers` is the shared helper.
84
+ - **`RedrivePublisher` capability and `redrive(bypass=True)` on the header-capable transports.**
85
+ Kafka, Pulsar, Artemis and Azure Service Bus becoming `HeaderPublisher`s means
86
+ `redrive(..., bypass=True)` now really stamps the `bq-replay-bypass` marker on them
87
+ (`RedriveItem.bypassed` is `True`; it was `False` before, because the transports could not carry
88
+ headers), and the write goes through the transport's header-carrying path. So that this path
89
+ loses nothing the ordinary redrive write keeps, `Kafka` and `Pulsar` implement the new optional
90
+ `publish_redriven(queue, body, headers, route_urn, key)`: Kafka's is the broker-confirmed settle
91
+ write (a delivery failure raises `KafkaDeliveryError`, the DLQ record is restored and not
92
+ committed past) and carries the DLQ record key unchanged; Pulsar's pins `bq-job` to the
93
+ dead-lettered delivery's routing property. `redrive` uses it when the transport has it.
94
+ - **`AsbTransport.settle_native_dead_letter(message, reason, description) -> bool`** (ADR-0039
95
+ section 5.1): the optional native-settle variant that reports what it did. It performs the same
96
+ settle as `settle_native` (which is unchanged) and returns `True` when the receiver's
97
+ `dead_letter_message` ran, `False` when it only abandoned the lock or had no handle.
98
+
12
99
  ## [1.24.0] - 2026-10-26
13
100
 
14
101
  Implements conformance **1.8.0** (vendored from the `v1.8.0` release asset, verified with
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: babelqueue
3
- Version: 1.24.0
3
+ Version: 1.25.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "1.24.0"
7
+ version = "1.25.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"
@@ -15,6 +15,7 @@ from . import dead_letter, gdpr, headers, idempotency, outbox, redrive, replay
15
15
  from .app import BabelQueue
16
16
  from .codec import SCHEMA_VERSION, SOURCE_LANG, EnvelopeCodec
17
17
  from .contracts import HasTraceId, PolyglotMessage
18
+ from .dead_letter import DeadLetterEvent, DeadLetterObserver, NativeDeadLetterSettler
18
19
  from .gdpr import Cipher, protect, unprotect
19
20
  from .headers import headers_from_context
20
21
  from .idempotency import IdempotencyStore, InMemoryStore
@@ -39,13 +40,14 @@ from .transport import (
39
40
  NativeSettler,
40
41
  ReceivedMessage,
41
42
  Redeliverer,
43
+ RedrivePublisher,
42
44
  Rewinder,
43
45
  RoutedPublisher,
44
46
  SettlePublisher,
45
47
  Transport,
46
48
  )
47
49
 
48
- __version__ = "1.24.0"
50
+ __version__ = "1.25.0"
49
51
 
50
52
  __all__ = [
51
53
  "BabelQueue",
@@ -64,7 +66,11 @@ __all__ = [
64
66
  "DeadLetterWriter",
65
67
  "HeaderPublisher",
66
68
  "NativeSettler",
69
+ "NativeDeadLetterSettler",
70
+ "DeadLetterEvent",
71
+ "DeadLetterObserver",
67
72
  "Redeliverer",
73
+ "RedrivePublisher",
68
74
  "Rewinder",
69
75
  "RoutedPublisher",
70
76
  "SettlePublisher",
@@ -32,10 +32,11 @@ import random
32
32
  import signal
33
33
  import threading
34
34
  from types import FrameType
35
- from typing import Any, Callable, Dict, Mapping, Optional
35
+ from typing import Any, Callable, Dict, Mapping, NamedTuple, Optional
36
36
 
37
37
  from . import dead_letter
38
38
  from .codec import EnvelopeCodec, has_forbidden_keys, parse_envelope
39
+ from .dead_letter import DeadLetterEvent, DeadLetterObserver, NativeDeadLetterSettler
39
40
  from ._poison_record import build_poison_record
40
41
  from .exceptions import BabelQueueError, UnknownUrnError
41
42
  from .headers import _headers_scope
@@ -101,6 +102,19 @@ class _ShutdownDeadline(BaseException):
101
102
  A ``BaseException`` so the dispatch-level ``except Exception`` does not swallow it."""
102
103
 
103
104
 
105
+ class PublishResult(NamedTuple):
106
+ """The outcome of :meth:`BabelQueue.publish_envelope`: ``(envelope, error)``.
107
+
108
+ ``envelope`` is the envelope that was handed to the transport (``None`` when building or
109
+ encoding it failed, so nothing was ever handed over); ``error`` is the exception that stopped
110
+ the publish (``None`` on success). A failed transport publish therefore still yields the
111
+ envelope that was attempted, which is what an observability wrapper needs.
112
+ """
113
+
114
+ envelope: Optional[Dict[str, Any]]
115
+ error: Optional[Exception]
116
+
117
+
104
118
  class BabelQueue:
105
119
  def __init__(
106
120
  self,
@@ -119,6 +133,7 @@ class BabelQueue:
119
133
  retry_jitter: str = "none",
120
134
  unknown_urn_release_delay: float = 0.0,
121
135
  shutdown_timeout: float = 30.0,
136
+ dead_letter_observer: Optional[DeadLetterObserver] = None,
122
137
  ) -> None:
123
138
  """
124
139
  ``retry_backoff`` / ``unknown_urn_release_delay`` are the seconds a released message
@@ -139,6 +154,12 @@ class BabelQueue:
139
154
  before releasing its message. Keep it above your longest handler run: a released
140
155
  message is redelivered and its handler runs again, and on brokers that count
141
156
  deliveries (SQS ``ApproximateReceiveCount``) the release also consumes an attempt.
157
+
158
+ ``dead_letter_observer`` (ADR-0039 section 5.1) is an optional callback
159
+ :data:`~babelqueue.dead_letter.DeadLetterObserver` the runtime calls once per dead-letter it
160
+ carried out itself, after the DLQ write (or the native dead-letter) is confirmed. A failed
161
+ write, a native no-op and a broker-side dead-letter are not reported. An exception it
162
+ raises is logged and never changes how the message is settled.
142
163
  """
143
164
  self.transport = transport if transport is not None else make_transport(broker_url)
144
165
  self.queue = queue
@@ -157,6 +178,7 @@ class BabelQueue:
157
178
  self.retry_jitter = retry_jitter
158
179
  self.unknown_urn_release_delay = float(unknown_urn_release_delay)
159
180
  self.shutdown_timeout = float(shutdown_timeout)
181
+ self.dead_letter_observer = dead_letter_observer
160
182
  self._handlers: Dict[str, Handler] = {}
161
183
  self._stop = threading.Event()
162
184
  self._rewind_pause = 1.0 # seconds to wait before a rewound gated message is re-polled
@@ -178,10 +200,7 @@ class BabelQueue:
178
200
  trace_id: Optional[str] = None,
179
201
  ) -> str:
180
202
  """Publish a message; returns its id (``meta.id``)."""
181
- target = queue or self.queue
182
- envelope = EnvelopeCodec.make(urn, data, queue=target, trace_id=trace_id)
183
- self.transport.publish(target, EnvelopeCodec.encode(envelope))
184
- return envelope["meta"]["id"]
203
+ return self._published_id(self.publish_envelope(urn, data, queue=queue, trace_id=trace_id))
185
204
 
186
205
  def publish_with_headers(
187
206
  self,
@@ -205,14 +224,60 @@ class BabelQueue:
205
224
  exactly as :func:`~babelqueue.redrive.redrive` degrades. Passing empty headers is
206
225
  equivalent to :meth:`publish`, so callers need not branch on transport capability.
207
226
  """
227
+ return self._published_id(
228
+ self.publish_envelope(urn, data, queue=queue, trace_id=trace_id, headers=headers)
229
+ )
230
+
231
+ def publish_envelope(
232
+ self,
233
+ urn: str,
234
+ data: Mapping[str, Any],
235
+ *,
236
+ queue: Optional[str] = None,
237
+ trace_id: Optional[str] = None,
238
+ headers: Optional[Mapping[str, str]] = None,
239
+ ) -> PublishResult:
240
+ """Publish like :meth:`publish` / :meth:`publish_with_headers`, but **return** the outcome
241
+ instead of raising: a :class:`PublishResult` ``(envelope, error)`` pair.
242
+
243
+ It is the one place that builds and sends the envelope. The two plain publish methods
244
+ delegate to it and re-raise ``error``, so their behaviour is unchanged (an empty ``queue``
245
+ resolves to :attr:`queue`, exactly as there). The difference is what a failure leaves
246
+ behind:
247
+
248
+ * ``make`` or ``encode`` failed (e.g. a blank URN, or ``data`` that is not JSON
249
+ serializable): ``envelope`` is ``None``, because nothing was ever handed to the
250
+ transport; ``error`` is the exception.
251
+ * the transport failed: ``envelope`` is the envelope that was handed to it (``meta.id``,
252
+ ``trace_id`` and ``meta.queue`` are readable), and ``error`` is the exception.
253
+ * success: ``(envelope, None)``.
254
+
255
+ ``headers`` are the out-of-band transport headers of :meth:`publish_with_headers`
256
+ (empty or ``None`` means a plain publish). The observability wrapper
257
+ :func:`babelqueue.otel.publish` uses it to read the produced identity on the failure path.
258
+ Only ``Exception`` is captured; ``KeyboardInterrupt`` and friends propagate.
259
+ """
208
260
  target = queue or self.queue
209
- envelope = EnvelopeCodec.make(urn, data, queue=target, trace_id=trace_id)
210
- body = EnvelopeCodec.encode(envelope)
211
- if headers and isinstance(self.transport, HeaderPublisher):
212
- self.transport.publish_with_headers(target, body, dict(headers))
213
- else:
214
- self.transport.publish(target, body)
215
- return envelope["meta"]["id"]
261
+ try:
262
+ envelope = EnvelopeCodec.make(urn, data, queue=target, trace_id=trace_id)
263
+ body = EnvelopeCodec.encode(envelope)
264
+ except Exception as exc: # noqa: BLE001 - returned to the caller, which re-raises it
265
+ return PublishResult(None, exc)
266
+ try:
267
+ if headers and isinstance(self.transport, HeaderPublisher):
268
+ self.transport.publish_with_headers(target, body, dict(headers))
269
+ else:
270
+ self.transport.publish(target, body)
271
+ except Exception as exc: # noqa: BLE001 - returned to the caller, which re-raises it
272
+ return PublishResult(envelope, exc)
273
+ return PublishResult(envelope, None)
274
+
275
+ @staticmethod
276
+ def _published_id(result: PublishResult) -> str:
277
+ if result.error is not None:
278
+ raise result.error
279
+ assert result.envelope is not None # no error means the envelope was built and sent
280
+ return result.envelope["meta"]["id"]
216
281
 
217
282
  # -- Register handlers --------------------------------------------------
218
283
 
@@ -504,16 +569,46 @@ class BabelQueue:
504
569
  mismatch does not flip to the body URN on the next attempt or in the DLQ."""
505
570
  publish_keeping_route(self.transport, queue, body, received.route_urn, received.key)
506
571
 
507
- def _write_dead_letter(self, received: ReceivedMessage, queue: str, body: str) -> None:
572
+ def _write_dead_letter(
573
+ self,
574
+ received: ReceivedMessage,
575
+ queue: str,
576
+ body: str,
577
+ *,
578
+ source_queue: Optional[str] = None,
579
+ reason: str = "",
580
+ ) -> None:
508
581
  """Write the dead-letter record ``body`` to ``queue`` and settle ``received``.
509
582
 
510
583
  A :class:`~babelqueue.transport.DeadLetterWriter` transport does both as one guarded step
511
- (a failed write propagates, never acks); otherwise publish, then acknowledge."""
584
+ (a failed write propagates, never acks); otherwise publish, then acknowledge.
585
+
586
+ The single chokepoint of every BabelQueue DLQ write (ADR-0039 section 5.2): once the write
587
+ is confirmed the dead-letter observer is told (``source_queue`` defaults to the queue
588
+ ``received`` came from, ``reason`` is empty for a raw body). A failed write raises before
589
+ that, so the observer is never called for it."""
512
590
  if isinstance(self.transport, DeadLetterWriter):
513
591
  self.transport.dead_letter(received, queue, body)
592
+ else:
593
+ self._publish_for(received, queue, body)
594
+ self._ack_settled(received)
595
+ self._notify_dead_letter(source_queue or received.queue, queue, reason)
596
+
597
+ def _notify_dead_letter(self, queue: str, dead_letter_queue: str, reason: str) -> None:
598
+ """Tell :attr:`dead_letter_observer` about a confirmed dead-letter. A raising observer is
599
+ logged and swallowed: it must never change the settle outcome (ADR-0039 section 5.1)."""
600
+ observer = self.dead_letter_observer
601
+ if observer is None:
514
602
  return
515
- self._publish_for(received, queue, body)
516
- self._ack_settled(received)
603
+ try:
604
+ observer(DeadLetterEvent(queue=queue, dead_letter_queue=dead_letter_queue, reason=reason))
605
+ except Exception: # noqa: BLE001 - an observer must never affect settlement
606
+ logger.error(
607
+ "The dead-letter observer failed for a message from queue %r; the dead-letter "
608
+ "itself was already settled and is unaffected.",
609
+ queue,
610
+ exc_info=True,
611
+ )
517
612
 
518
613
  def _ack_settled(self, received: ReceivedMessage) -> None:
519
614
  """Acknowledge a message whose outcome is already final (handled, dead-lettered,
@@ -540,8 +635,13 @@ class BabelQueue:
540
635
  """Hand ``received`` to the transport's :class:`~babelqueue.transport.NativeSettler`
541
636
  capability and report whether it did: ``False`` means the transport has none and the caller
542
637
  keeps its previous settle. The event is logged and never retried by the SDK; a failing call
543
- is logged too and leaves the message unacknowledged (the broker redelivers it)."""
544
- if not isinstance(self.transport, NativeSettler):
638
+ is logged too and leaves the message unacknowledged (the broker redelivers it).
639
+
640
+ A transport that also implements :class:`~babelqueue.dead_letter.NativeDeadLetterSettler`
641
+ is settled through that variant instead, and the dead-letter observer is told only when it
642
+ returns ``True`` (a native dead-letter the SDK carried out, ADR-0039 section 5.1)."""
643
+ transport = self.transport
644
+ if not isinstance(transport, (NativeDeadLetterSettler, NativeSettler)):
545
645
  return False
546
646
  logger.warning(
547
647
  "Left to the broker's native mechanism (not acknowledged, not retried): queue %r, "
@@ -549,7 +649,14 @@ class BabelQueue:
549
649
  received.queue, received.route_urn, reason,
550
650
  )
551
651
  try:
552
- self.transport.settle_native(received, reason, description)
652
+ if isinstance(transport, NativeDeadLetterSettler):
653
+ # The optional variant (ADR-0039 section 5.1) says whether the broker really
654
+ # dead-lettered the message: only a literal ``True`` is a dead-letter the SDK
655
+ # carried out (an abandon, or the SQS-style no-op, is not).
656
+ if transport.settle_native_dead_letter(received, reason, description) is True:
657
+ self._notify_dead_letter(received.queue, "", "")
658
+ else:
659
+ transport.settle_native(received, reason, description)
553
660
  except Exception: # noqa: BLE001 - reported, never kills the loop
554
661
  logger.error(
555
662
  "Native settle of a message on queue %r failed; it stays unacknowledged and will "
@@ -735,7 +842,7 @@ class BabelQueue:
735
842
  self._ack_settled(received)
736
843
  return
737
844
  if self.dead_letter_enabled or self.on_unknown_urn == UnknownUrnStrategy.DEAD_LETTER:
738
- self._settle_version_gated(received, always=True)
845
+ self._settle_version_gated(received, always=True, reason="poison")
739
846
  return
740
847
  if self._retry_tiers_unavailable():
741
848
  # broker-bindings §6.5 / ADR-0036 decision 5: no retry tier, no dead-lettering and not
@@ -836,7 +943,10 @@ class BabelQueue:
836
943
  exception=BabelQueueError.__name__,
837
944
  )["dead_letter"]
838
945
  self._write_dead_letter(
839
- received, self._dead_letter_target(received), build_poison_record(received.body, block)
946
+ received,
947
+ self._dead_letter_target(received),
948
+ build_poison_record(received.body, block),
949
+ reason="poison",
840
950
  )
841
951
 
842
952
  def _dead_letter_target(self, received: ReceivedMessage) -> str:
@@ -850,11 +960,16 @@ class BabelQueue:
850
960
  return self.transport.dead_letter_queue_name(received.queue, self.dead_letter_suffix)
851
961
  return received.queue + (".dlq" if self.dead_letter_suffix is None else self.dead_letter_suffix)
852
962
 
853
- def _settle_version_gated(self, received: ReceivedMessage, always: bool = False) -> None:
963
+ def _settle_version_gated(
964
+ self, received: ReceivedMessage, always: bool = False, reason: str = ""
965
+ ) -> None:
854
966
  """Dead-letter a version-gated delivery directly (no retry). With no DLQ set it is left to the
855
967
  transport's native mechanism when the transport has the :class:`~babelqueue.transport.NativeSettler`
856
968
  capability (SQS, Azure Service Bus; ADR-0037 decision 1 rule 3) and dropped otherwise.
857
969
 
970
+ ``reason`` is what the dead-letter observer is told (``""`` for a raw version-gated body;
971
+ ``"poison"`` for a Kafka poison record, whose DLQ record the transport wraps with that reason).
972
+
858
973
  ``always`` writes the DLQ record in every configuration, ignoring ``dead_letter_enabled``.
859
974
  A Kafka poison record reaches it with the DLQ already on (:meth:`_route_poison`). The Redis
860
975
  poison reservation does not come through here: it has its own ADR-0035 record
@@ -866,7 +981,9 @@ class BabelQueue:
866
981
  (see :meth:`dispatch`), never retried.
867
982
  """
868
983
  if always or self.dead_letter_enabled or self.on_unknown_urn == UnknownUrnStrategy.DEAD_LETTER:
869
- self._write_dead_letter(received, self._dead_letter_target(received), received.body)
984
+ self._write_dead_letter(
985
+ received, self._dead_letter_target(received), received.body, reason=reason
986
+ )
870
987
  return
871
988
  # No DLQ to hold it (broker-bindings §5.7 rule 3). A transport with a native mechanism (SQS,
872
989
  # Azure Service Bus) leaves it to that mechanism instead of dropping it (ADR-0037 decision 1).
@@ -927,7 +1044,11 @@ class BabelQueue:
927
1044
  exception=(type(exc).__name__ if exc is not None else None),
928
1045
  )
929
1046
  self._write_dead_letter(
930
- received, self._dead_letter_target(received), EnvelopeCodec.encode(annotated)
1047
+ received,
1048
+ self._dead_letter_target(received),
1049
+ EnvelopeCodec.encode(annotated),
1050
+ source_queue=original_queue,
1051
+ reason=reason,
931
1052
  )
932
1053
 
933
1054
 
@@ -21,6 +21,13 @@ deliveries. (The Java JMS binding reads ``JMSXDeliveryCount`` which is 1-based a
21
21
  arriving at the same 0-based ``attempts``.) A message left un-accepted is redelivered
22
22
  (at-least-once).
23
23
 
24
+ Out-of-band transport headers (a W3C ``traceparent`` for cross-hop span linkage, ADR-0028) ride as
25
+ extra AMQP 1.0 application-properties (string-valued, hyphen-free names) beside the ``bq_*`` projection:
26
+ :meth:`ArtemisTransport.publish_with_headers` writes them (the projection wins a collision) and
27
+ :meth:`~ArtemisTransport.pop` surfaces every non-projection application-property on
28
+ :attr:`~babelqueue.transport.ReceivedMessage.headers`; the ``bq_*`` projection keys are not leaked. The
29
+ body is untouched (GR-1).
30
+
24
31
  This implements §7 of the broker-bindings contract. The envelope is unchanged
25
32
  (``schema_version`` stays 1); Apache ActiveMQ Artemis is purely additive.
26
33
 
@@ -38,6 +45,7 @@ from typing import Any, Dict, Optional
38
45
  from urllib.parse import quote
39
46
 
40
47
  from .codec import ASCII_WHITESPACE, SCHEMA_VERSION, EnvelopeCodec, parse_envelope
48
+ from .headers import out_of_band_headers
41
49
  from .transport import ReceivedMessage, Transport, truncate_declared_version
42
50
 
43
51
  _logger = logging.getLogger("babelqueue.artemis")
@@ -157,12 +165,14 @@ class ArtemisTransport(Transport):
157
165
  except (TypeError, ValueError): # pragma: no cover - defensive
158
166
  return None
159
167
 
160
- def _message(self, body: str) -> Any:
161
- """Build the proton Message projecting the §7 JMS-readable metadata."""
168
+ def _message(self, body: str, headers: Optional[Dict[str, str]] = None) -> Any:
169
+ """Build the proton Message projecting the §7 JMS-readable metadata. ``headers`` are
170
+ out-of-band application-properties (ADR-0028) merged *under* the ``bq_*`` projection."""
162
171
  from proton import Message, symbol
163
172
 
164
173
  message: Any = Message(body=body)
165
- props = self._projection(body)
174
+ props = out_of_band_headers(headers)
175
+ props.update(self._projection(body))
166
176
  if props:
167
177
  message.properties = props
168
178
  correlation_id = self._correlation_id(body)
@@ -242,6 +252,13 @@ class ArtemisTransport(Transport):
242
252
  def publish(self, queue: str, body: str) -> None:
243
253
  self._sender(queue).send(self._message(body))
244
254
 
255
+ def publish_with_headers(self, queue: str, body: str, headers: Dict[str, str]) -> None:
256
+ """:meth:`publish` with out-of-band ``headers`` (a W3C ``traceparent``, the replay-bypass
257
+ marker ...) carried as extra AMQP 1.0 application-properties beside the ``bq_*`` projection
258
+ (:class:`~babelqueue.transport.HeaderPublisher`, ADR-0028, broker-bindings 7.2). The
259
+ projection wins a key collision and the body is byte-identical to :meth:`publish`."""
260
+ self._sender(queue).send(self._message(body, headers))
261
+
245
262
  def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
246
263
  wait = self._receive_timeout_millis / 1000.0
247
264
  if timeout and timeout > 0:
@@ -276,7 +293,12 @@ class ArtemisTransport(Transport):
276
293
  route_urn=UNSUPPORTED_SCHEMA_ROUTE_PREFIX + quote(unsupported, safe=""),
277
294
  )
278
295
  body = self._reconcile(self._payload(message), self._delivery_count(message))
279
- return ReceivedMessage(body=body, queue=queue, handle=receiver)
296
+ return ReceivedMessage(
297
+ body=body,
298
+ queue=queue,
299
+ handle=receiver,
300
+ headers=out_of_band_headers(getattr(message, "properties", None)),
301
+ )
280
302
 
281
303
  def ack(self, message: ReceivedMessage) -> None:
282
304
  if message.handle is None:
@@ -16,6 +16,13 @@ A version-gated message (``bq-schema-version`` the consumer does not support) or
16
16
  dead-lettering off, not completed: :meth:`AsbTransport.settle_native` dead-letters it natively into
17
17
  ``$DeadLetterQueue`` (ADR-0037).
18
18
 
19
+ Out-of-band transport headers (a W3C ``traceparent`` for cross-hop span linkage, ADR-0028) ride as
20
+ extra application properties (string-valued) beside the ``bq-*`` projection:
21
+ :meth:`AsbTransport.publish_with_headers` writes them (the projection wins a collision) and
22
+ :meth:`~AsbTransport.pop` surfaces every non-projection application property on
23
+ :attr:`~babelqueue.transport.ReceivedMessage.headers`; the ``bq-*`` projection keys are not leaked.
24
+ The body is untouched (GR-1).
25
+
19
26
  This implements §4 of the broker-bindings contract. The envelope is unchanged
20
27
  (``schema_version`` stays 1); Azure Service Bus is purely additive.
21
28
 
@@ -33,6 +40,7 @@ from typing import Any, Dict, Optional
33
40
  from urllib.parse import quote, urlsplit
34
41
 
35
42
  from .codec import ASCII_WHITESPACE, SCHEMA_VERSION, EnvelopeCodec, parse_envelope
43
+ from .headers import out_of_band_headers
36
44
  from .transport import ReceivedMessage, Transport, truncate_declared_version
37
45
 
38
46
  _logger = logging.getLogger("babelqueue.asb")
@@ -228,11 +236,23 @@ class AsbTransport(Transport):
228
236
  # -- Transport ----------------------------------------------------------
229
237
 
230
238
  def publish(self, queue: str, body: str) -> None:
239
+ self._send(queue, body, None)
240
+
241
+ def publish_with_headers(self, queue: str, body: str, headers: Dict[str, str]) -> None:
242
+ """:meth:`publish` with out-of-band ``headers`` (a W3C ``traceparent``, the replay-bypass
243
+ marker ...) carried as extra string-valued application properties beside the ``bq-*``
244
+ projection (:class:`~babelqueue.transport.HeaderPublisher`, ADR-0028, broker-bindings 4.3).
245
+ The projection wins a key collision and the body is byte-identical to :meth:`publish`."""
246
+ self._send(queue, body, headers)
247
+
248
+ def _send(self, queue: str, body: str, headers: Optional[Dict[str, str]]) -> None:
231
249
  from azure.servicebus import ServiceBusMessage
232
250
 
233
251
  kwargs = self._projection(body)
234
- if kwargs.get("application_properties"):
235
- kwargs["application_properties"] = _amqp_typed(kwargs["application_properties"])
252
+ properties: Dict[str, Any] = dict(out_of_band_headers(headers))
253
+ properties.update(kwargs.get("application_properties") or {})
254
+ if properties:
255
+ kwargs["application_properties"] = _amqp_typed(properties)
236
256
  message = ServiceBusMessage(body, **kwargs)
237
257
  self._sender(queue).send_messages(message)
238
258
 
@@ -266,7 +286,12 @@ class AsbTransport(Transport):
266
286
  route_urn=UNSUPPORTED_SCHEMA_ROUTE_PREFIX + quote(unsupported, safe=""),
267
287
  )
268
288
  body = self._reconcile(str(message), getattr(message, "delivery_count", None))
269
- return ReceivedMessage(body=body, queue=queue, handle=message)
289
+ return ReceivedMessage(
290
+ body=body,
291
+ queue=queue,
292
+ handle=message,
293
+ headers=out_of_band_headers(getattr(message, "application_properties", None)),
294
+ )
270
295
 
271
296
  def ack(self, message: ReceivedMessage) -> None:
272
297
  if message.handle is None:
@@ -297,14 +322,26 @@ class AsbTransport(Transport):
297
322
  ``$DeadLetterQueue``. Nothing is completed, re-published or retried, and the body is
298
323
  unchanged. A receiver without ``dead_letter_message`` abandons the lock instead (also a
299
324
  conformant native settle: ``MaxDeliveryCount`` takes over)."""
325
+ self.settle_native_dead_letter(message, reason, description)
326
+
327
+ def settle_native_dead_letter(
328
+ self, message: ReceivedMessage, reason: str, description: str
329
+ ) -> bool:
330
+ """:meth:`settle_native` that also reports what it did (the optional native-settle variant
331
+ of ADR-0039 section 5.1): ``True`` when the receiver's ``dead_letter_message`` was called,
332
+ so the message really went to ``$DeadLetterQueue``, and ``False`` when it only abandoned
333
+ the lock (a receiver without ``dead_letter_message``) or had no handle to settle. The
334
+ runtime prefers it over :meth:`settle_native` and counts a dead-letter only on ``True``.
335
+ The settle itself is identical to :meth:`settle_native`, which stays as it was."""
300
336
  if message.handle is None:
301
- return
337
+ return False
302
338
  receiver = self._receiver(message.queue)
303
339
  dead_letter = getattr(receiver, "dead_letter_message", None)
304
340
  if dead_letter is not None:
305
341
  dead_letter(message.handle, reason=reason, error_description=description)
306
- return
342
+ return True
307
343
  self.abandon(message)
344
+ return False
308
345
 
309
346
  def close(self) -> None: # pragma: no cover - resource cleanup
310
347
  for resource in (*self._senders.values(), *self._receivers.values(), self._client):