babelqueue 1.15.0__tar.gz → 1.16.1__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 (97) hide show
  1. {babelqueue-1.15.0 → babelqueue-1.16.1}/.github/workflows/ci.yml +56 -8
  2. {babelqueue-1.15.0 → babelqueue-1.16.1}/.github/workflows/release.yml +3 -3
  3. {babelqueue-1.15.0 → babelqueue-1.16.1}/CHANGELOG.md +55 -0
  4. {babelqueue-1.15.0 → babelqueue-1.16.1}/PKG-INFO +38 -1
  5. {babelqueue-1.15.0 → babelqueue-1.16.1}/README.md +37 -0
  6. {babelqueue-1.15.0 → babelqueue-1.16.1}/pyproject.toml +1 -1
  7. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/__init__.py +3 -1
  8. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/app.py +76 -15
  9. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/artemis_transport.py +64 -2
  10. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/asb_transport.py +74 -3
  11. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/kafka_transport.py +134 -5
  12. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/pulsar_transport.py +8 -7
  13. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/sqs_transport.py +62 -5
  14. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/transport.py +28 -0
  15. babelqueue-1.16.1/tests/conformance/CONFORMANCE_VERSION +1 -0
  16. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/manifest.json +133 -0
  17. babelqueue-1.16.1/tests/fakes/confluent_kafka/__init__.py +73 -0
  18. babelqueue-1.16.1/tests/fakes/confluent_kafka/admin.py +20 -0
  19. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_app.py +103 -20
  20. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_artemis_conformance.py +27 -0
  21. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_artemis_transport.py +132 -2
  22. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_asb_conformance.py +30 -0
  23. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_asb_transport.py +138 -2
  24. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_kafka_conformance.py +29 -0
  25. babelqueue-1.16.1/tests/test_kafka_integration.py +169 -0
  26. babelqueue-1.16.1/tests/test_kafka_integration_logic.py +44 -0
  27. babelqueue-1.16.1/tests/test_kafka_transport.py +592 -0
  28. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_pulsar_conformance.py +27 -0
  29. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_pulsar_transport.py +13 -8
  30. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_sqs_conformance.py +27 -0
  31. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_sqs_transport.py +159 -0
  32. babelqueue-1.15.0/tests/conformance/CONFORMANCE_VERSION +0 -1
  33. babelqueue-1.15.0/tests/test_kafka_transport.py +0 -194
  34. {babelqueue-1.15.0 → babelqueue-1.16.1}/.github/FUNDING.yml +0 -0
  35. {babelqueue-1.15.0 → babelqueue-1.16.1}/.github/dependabot.yml +0 -0
  36. {babelqueue-1.15.0 → babelqueue-1.16.1}/.gitignore +0 -0
  37. {babelqueue-1.15.0 → babelqueue-1.16.1}/LICENSE +0 -0
  38. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/celery.py +0 -0
  39. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/codec.py +0 -0
  40. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/contracts.py +0 -0
  41. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/dead_letter.py +0 -0
  42. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/django/__init__.py +0 -0
  43. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/django/apps.py +0 -0
  44. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/django/management/__init__.py +0 -0
  45. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/django/management/commands/__init__.py +0 -0
  46. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  47. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/exceptions.py +0 -0
  48. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/gdpr.py +0 -0
  49. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/headers.py +0 -0
  50. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/idempotency.py +0 -0
  51. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/otel.py +0 -0
  52. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/outbox.py +0 -0
  53. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/pika_transport.py +0 -0
  54. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/py.typed +0 -0
  55. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/redis_transport.py +0 -0
  56. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/redrive.py +0 -0
  57. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/replay.py +0 -0
  58. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/routing.py +0 -0
  59. {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/schema.py +0 -0
  60. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/data-array-rejected.json +0 -0
  61. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/dead-lettered.json +0 -0
  62. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
  63. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
  64. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
  65. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
  66. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
  67. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
  68. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  69. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  70. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/order-created.json +0 -0
  71. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  72. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/unknown-lang.json +0 -0
  73. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
  74. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
  75. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/urn-alias.json +0 -0
  76. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/schema/message-envelope.schema.json +0 -0
  77. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/fixtures/dead-lettered.json +0 -0
  78. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/fixtures/order-created.json +0 -0
  79. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_behaviour_conformance.py +0 -0
  80. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_celery.py +0 -0
  81. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_codec.py +0 -0
  82. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_conformance.py +0 -0
  83. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_dead_letter.py +0 -0
  84. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_django.py +0 -0
  85. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_gdpr.py +0 -0
  86. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_headers.py +0 -0
  87. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_idempotency.py +0 -0
  88. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_otel.py +0 -0
  89. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_outbox.py +0 -0
  90. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_overhead.py +0 -0
  91. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_pika_transport.py +0 -0
  92. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_redis_transport.py +0 -0
  93. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_redrive.py +0 -0
  94. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_replay.py +0 -0
  95. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_schema.py +0 -0
  96. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_schema_conformance.py +0 -0
  97. {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_shutdown.py +0 -0
@@ -4,6 +4,7 @@ on:
4
4
  push:
5
5
  branches: [main]
6
6
  pull_request:
7
+ workflow_dispatch:
7
8
 
8
9
  permissions:
9
10
  contents: read
@@ -17,10 +18,10 @@ jobs:
17
18
  matrix:
18
19
  python: ['3.9', '3.10', '3.11', '3.12', '3.13']
19
20
  steps:
20
- - uses: actions/checkout@v5
21
+ - uses: actions/checkout@v7
21
22
 
22
23
  - name: Setup Python
23
- uses: actions/setup-python@v5
24
+ uses: actions/setup-python@v7
24
25
  with:
25
26
  python-version: ${{ matrix.python }}
26
27
 
@@ -36,9 +37,9 @@ jobs:
36
37
  name: Static analysis (ruff + mypy)
37
38
  runs-on: ubuntu-latest
38
39
  steps:
39
- - uses: actions/checkout@v5
40
+ - uses: actions/checkout@v7
40
41
  - name: Setup Python
41
- uses: actions/setup-python@v5
42
+ uses: actions/setup-python@v7
42
43
  with:
43
44
  python-version: '3.12'
44
45
  - name: Install (dev + all adapters for type context)
@@ -79,10 +80,10 @@ jobs:
79
80
  ports:
80
81
  - 9324:9324
81
82
  steps:
82
- - uses: actions/checkout@v5
83
+ - uses: actions/checkout@v7
83
84
 
84
85
  - name: Setup Python
85
- uses: actions/setup-python@v5
86
+ uses: actions/setup-python@v7
86
87
  with:
87
88
  python-version: '3.12'
88
89
 
@@ -111,14 +112,61 @@ jobs:
111
112
  AWS_SECRET_ACCESS_KEY: test
112
113
  run: pytest --cov=babelqueue --cov-report=term-missing --cov-fail-under=90
113
114
 
115
+ integration-live:
116
+ name: Integration (live Kafka)
117
+ # Opt-in, not a required check: runs on push to main and on demand, never on pull requests,
118
+ # and is deliberately NOT in ci-green's needs. (The `integration` job above is the required,
119
+ # coverage-gated one for Redis / RabbitMQ / SQS.)
120
+ if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
121
+ runs-on: ubuntu-latest
122
+ timeout-minutes: 20
123
+ services:
124
+ kafka:
125
+ image: apache/kafka:3.9.0
126
+ ports:
127
+ - 9092:9092
128
+ env:
129
+ KAFKA_NODE_ID: 1
130
+ KAFKA_PROCESS_ROLES: broker,controller
131
+ KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
132
+ KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
133
+ KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
134
+ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
135
+ KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
136
+ KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
137
+ KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
138
+ KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
139
+ KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
140
+ options: >-
141
+ --health-cmd "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092"
142
+ --health-interval 10s --health-timeout 10s --health-retries 12 --health-start-period 20s
143
+ steps:
144
+ - uses: actions/checkout@v7
145
+
146
+ - name: Setup Python
147
+ uses: actions/setup-python@v7
148
+ with:
149
+ python-version: '3.12'
150
+
151
+ - name: Install (kafka extra + dev)
152
+ run: |
153
+ python -m pip install --upgrade pip
154
+ pip install -e ".[kafka,dev]"
155
+
156
+ - name: Live Kafka tests
157
+ env:
158
+ BQ_INTEGRATION: "1"
159
+ BQ_KAFKA_BROKERS: localhost:9092
160
+ run: pytest tests/test_kafka_integration.py -v
161
+
114
162
  conformance:
115
163
  name: Conformance suite in sync
116
164
  runs-on: ubuntu-latest
117
165
  steps:
118
- - uses: actions/checkout@v5
166
+ - uses: actions/checkout@v7
119
167
  - name: Verify vendored conformance matches the canonical suite
120
168
  run: |
121
- git clone --depth 1 --branch v1.0.0 https://github.com/BabelQueue/conformance.git "$RUNNER_TEMP/conformance"
169
+ git clone --depth 1 --branch v1.2.0 https://github.com/BabelQueue/conformance.git "$RUNNER_TEMP/conformance"
122
170
  diff -u "$RUNNER_TEMP/conformance/CONFORMANCE_VERSION" "tests/conformance/CONFORMANCE_VERSION"
123
171
  diff -ru "$RUNNER_TEMP/conformance/manifest.json" "tests/conformance/manifest.json"
124
172
  diff -ru "$RUNNER_TEMP/conformance/fixtures" "tests/conformance/fixtures"
@@ -18,10 +18,10 @@ jobs:
18
18
  id-token: write # PyPI Trusted Publishing (OIDC) — no API token needed
19
19
  contents: write # create the GitHub release
20
20
  steps:
21
- - uses: actions/checkout@v5
21
+ - uses: actions/checkout@v7
22
22
 
23
23
  - name: Setup Python
24
- uses: actions/setup-python@v5
24
+ uses: actions/setup-python@v7
25
25
  with:
26
26
  python-version: '3.12'
27
27
 
@@ -41,6 +41,6 @@ jobs:
41
41
  # password: ${{ secrets.PYPI_API_TOKEN }}
42
42
 
43
43
  - name: Create GitHub release
44
- uses: softprops/action-gh-release@v2
44
+ uses: softprops/action-gh-release@v3
45
45
  with:
46
46
  generate_release_notes: true
@@ -9,6 +9,61 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.16.1] - 2026-10-11
13
+
14
+ ### Changed
15
+ - **Schema-version gate rejection now dead-letters the incoming body, byte-identical.** A message
16
+ whose `bq-schema-version` (`bq_schema_version` on Artemis) is unsupported is rejected *without
17
+ decoding it* (broker-bindings §3.7/§4.7/§5.7/§6.7/§7.7) and, when the DLQ is enabled, the DLQ
18
+ record is the raw body as received, with no `dead_letter` block. Previously the core decoded
19
+ the envelope and wrote an annotated `dead_letter{reason:"unknown_urn"}` record. This aligns the
20
+ Python SDK with Java, .NET and Node. A DLQ consumer that expected the annotation for gated
21
+ messages is affected; a failed DLQ write still leaves the message unsettled (rewound on Kafka),
22
+ and DLQ off / `UnknownUrnStrategy.DELETE` still drops it. No wire or API change.
23
+
24
+ ### Fixed
25
+ - **Kafka: a record whose settlement raised was silently lost on a consume-loop restart.** When a
26
+ retry / release / dead-letter `publish` failed (or the loop was otherwise interrupted before
27
+ `ack`), `consume()` ended with the record neither acked nor rewound while `poll` had already
28
+ advanced the position; the restarted loop received the *next* record and acking it committed
29
+ past the unsettled one (Kafka offsets are cumulative). `KafkaTransport` now remembers the
30
+ record `pop` handed out and re-seeks it on the next `pop` unless it was acked or rewound.
31
+ No API or wire change.
32
+
33
+ ## [1.16.0] - 2026-10-10
34
+
35
+ ### Added
36
+ - New optional `Rewinder` protocol (`rewind(message)`), implemented by `KafkaTransport` and
37
+ exported from `babelqueue` (public API, MINOR). The runtime calls it after a failed
38
+ version-gate dead-letter write and then pauses briefly (1 s) before the next poll.
39
+
40
+ ### Fixed
41
+ - **`bq-schema-version` pre-decode gate on the SQS, Azure Service Bus, Kafka and Artemis
42
+ transports** (`pop`). The broker's schema-version property is read **before** the body is
43
+ decoded (broker-bindings §3.7 / §4.7 / §6.7 / §7.7), mirroring the existing Pulsar gate:
44
+ a missing or blank value (empty or only ASCII whitespace) takes the normal path; anything
45
+ else is compared untrimmed to `"1"` and a mismatch is returned with the raw body and the
46
+ reserved route `babelqueue:unsupported-schema-version:<v>` (version percent-escaped) without
47
+ decoding it. SQS/ASB/Kafka read `bq-schema-version`, Artemis `bq_schema_version`
48
+ (ADR-0017); ASB and Artemis also accept an integer `1` (a `bool` is not `1`). The conformance suite now reads each binding's
49
+ `schema_version_gate` table (including Pulsar's). No wire change.
50
+ - **A version-gated delivery is dead-lettered directly, never retried.** `BabelQueue` now
51
+ sends a message under the reserved gate route straight to the DLQ (reason `unknown_urn`,
52
+ body untouched) when `dead_letter` is enabled and drops it otherwise, regardless of
53
+ `max_attempts` / `on_unknown_urn`. A retry would re-publish through paths that rebuild the
54
+ version header from the (valid) body (ASB/Kafka/Artemis) or never advance the attempt
55
+ counter (SQS), letting the gate be bypassed or the message loop. Applies to Pulsar too
56
+ (previously it retried up to `max_attempts` first).
57
+ If the dead-letter write itself fails, the message is still never retried: it is left
58
+ unacknowledged (an error is logged) so the broker redelivers it and the gate rejects it again
59
+ (SQS at the visibility timeout, Azure Service Bus at lock expiry, Artemis when the link or
60
+ connection closes). Kafka commits offsets cumulatively, so merely not acknowledging the
61
+ record would lose it once a later record is committed: `KafkaTransport` now `seek`s the
62
+ partition back to the failed record's offset so it is polled again and the dead-letter write
63
+ is retried (no commit moves past it).
64
+ - The rejected `bq-schema-version` value is capped at 64 characters plus `...` in the log and
65
+ the route.
66
+
12
67
  ## [1.15.0] - 2026-10-10
13
68
 
14
69
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: babelqueue
3
- Version: 1.15.0
3
+ Version: 1.16.1
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
@@ -178,6 +178,43 @@ app.run() # consume forever (Ctrl-C to stop)
178
178
  the message unchanged once `shutdown_timeout` expires. Delivery is
179
179
  **at-least-once** — keep handlers idempotent (see the idempotency helper).
180
180
 
181
+ ### Unsupported `schema_version` (SQS, Azure Service Bus, Kafka, Artemis, Pulsar)
182
+
183
+ These transports read the binding's schema-version property (`bq-schema-version`; Artemis
184
+ `bq_schema_version`) **before** decoding the body. A missing or blank value (empty or only
185
+ ASCII whitespace) follows the normal path; any other value must be exactly `"1"` (compared
186
+ untrimmed; Azure Service Bus and Artemis also accept the integer `1`). A message carrying
187
+ anything else is not decoded: it reaches the runtime on the reserved route
188
+ `babelqueue:unsupported-schema-version:<v>` (the version percent-escaped) and is **never
189
+ retried** — it goes straight to the dead-letter queue (the incoming body, byte-identical,
190
+ with no `dead_letter` block) when `dead_letter` is enabled, and is dropped otherwise, whatever `max_attempts` is.
191
+ It does not depend on a broker `RedrivePolicy`. If the dead-letter write fails the message is
192
+ left unacknowledged (an error is logged), is never retried and never reaches a handler; the
193
+ dead-letter write is attempted again when the broker brings it back, which depends on the
194
+ broker:
195
+
196
+ - **SQS** — redelivered when the visibility timeout expires.
197
+ - **Azure Service Bus** — redelivered when the message lock expires.
198
+ - **Artemis** — an unsettled AMQP 1.0 delivery is returned only when the link or connection
199
+ closes, so it stays unsettled until then.
200
+ - **Kafka** — offsets are committed cumulatively per partition, so merely skipping the commit
201
+ would lose the record once a later one is committed. The transport therefore implements
202
+ the optional `Rewinder` protocol: it `seek`s the partition back to the failed record's
203
+ offset and pauses `_rewind_pause` (1 s by default, so an unavailable DLQ is not hammered)
204
+ before the record is polled again; the gate rejects it again and the dead-letter write
205
+ is retried. While the dead-letter write keeps failing (for example the `.dlq` topic is not
206
+ writable) the partition **stalls on that record** (head-of-line blocking, consumer lag
207
+ grows) and the worker waits the pause on every attempt, which also delays its other
208
+ partitions and queues. The commit does not move past the record as long as the seek
209
+ succeeds; if the seek itself fails, an error naming the topic, partition and offset is
210
+ logged and a later commit on that partition may skip the record, so recover it by hand from
211
+ that offset. If the partition was lost to a rebalance, the new owner resumes from the last
212
+ committed offset and the record is not lost.
213
+ - **Pulsar** — acknowledgement is per message; the unacknowledged message is redelivered by
214
+ the broker.
215
+
216
+ A warning naming the version (cut at 64 characters plus `...`) and queue is logged.
217
+
181
218
  ### SQS release and poison messages
182
219
 
183
220
  On a handler failure, unknown-URN `release` or shutdown release, the SQS
@@ -124,6 +124,43 @@ app.run() # consume forever (Ctrl-C to stop)
124
124
  the message unchanged once `shutdown_timeout` expires. Delivery is
125
125
  **at-least-once** — keep handlers idempotent (see the idempotency helper).
126
126
 
127
+ ### Unsupported `schema_version` (SQS, Azure Service Bus, Kafka, Artemis, Pulsar)
128
+
129
+ These transports read the binding's schema-version property (`bq-schema-version`; Artemis
130
+ `bq_schema_version`) **before** decoding the body. A missing or blank value (empty or only
131
+ ASCII whitespace) follows the normal path; any other value must be exactly `"1"` (compared
132
+ untrimmed; Azure Service Bus and Artemis also accept the integer `1`). A message carrying
133
+ anything else is not decoded: it reaches the runtime on the reserved route
134
+ `babelqueue:unsupported-schema-version:<v>` (the version percent-escaped) and is **never
135
+ retried** — it goes straight to the dead-letter queue (the incoming body, byte-identical,
136
+ with no `dead_letter` block) when `dead_letter` is enabled, and is dropped otherwise, whatever `max_attempts` is.
137
+ It does not depend on a broker `RedrivePolicy`. If the dead-letter write fails the message is
138
+ left unacknowledged (an error is logged), is never retried and never reaches a handler; the
139
+ dead-letter write is attempted again when the broker brings it back, which depends on the
140
+ broker:
141
+
142
+ - **SQS** — redelivered when the visibility timeout expires.
143
+ - **Azure Service Bus** — redelivered when the message lock expires.
144
+ - **Artemis** — an unsettled AMQP 1.0 delivery is returned only when the link or connection
145
+ closes, so it stays unsettled until then.
146
+ - **Kafka** — offsets are committed cumulatively per partition, so merely skipping the commit
147
+ would lose the record once a later one is committed. The transport therefore implements
148
+ the optional `Rewinder` protocol: it `seek`s the partition back to the failed record's
149
+ offset and pauses `_rewind_pause` (1 s by default, so an unavailable DLQ is not hammered)
150
+ before the record is polled again; the gate rejects it again and the dead-letter write
151
+ is retried. While the dead-letter write keeps failing (for example the `.dlq` topic is not
152
+ writable) the partition **stalls on that record** (head-of-line blocking, consumer lag
153
+ grows) and the worker waits the pause on every attempt, which also delays its other
154
+ partitions and queues. The commit does not move past the record as long as the seek
155
+ succeeds; if the seek itself fails, an error naming the topic, partition and offset is
156
+ logged and a later commit on that partition may skip the record, so recover it by hand from
157
+ that offset. If the partition was lost to a rebalance, the new owner resumes from the last
158
+ committed offset and the record is not lost.
159
+ - **Pulsar** — acknowledgement is per message; the unacknowledged message is redelivered by
160
+ the broker.
161
+
162
+ A warning naming the version (cut at 64 characters plus `...`) and queue is logged.
163
+
127
164
  ### SQS release and poison messages
128
165
 
129
166
  On a handler failure, unknown-URN `release` or shutdown release, the SQS
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "1.15.0"
7
+ version = "1.16.1"
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"
@@ -34,11 +34,12 @@ from .transport import (
34
34
  InMemoryTransport,
35
35
  ReceivedMessage,
36
36
  Redeliverer,
37
+ Rewinder,
37
38
  RoutedPublisher,
38
39
  Transport,
39
40
  )
40
41
 
41
- __version__ = "1.15.0"
42
+ __version__ = "1.16.1"
42
43
 
43
44
  __all__ = [
44
45
  "BabelQueue",
@@ -53,6 +54,7 @@ __all__ = [
53
54
  "ReceivedMessage",
54
55
  "HeaderPublisher",
55
56
  "Redeliverer",
57
+ "Rewinder",
56
58
  "RoutedPublisher",
57
59
  "BabelQueueError",
58
60
  "UnknownUrnError",
@@ -43,6 +43,7 @@ from .transport import (
43
43
  HeaderPublisher,
44
44
  ReceivedMessage,
45
45
  Redeliverer,
46
+ Rewinder,
46
47
  Transport,
47
48
  make_transport,
48
49
  publish_keeping_route,
@@ -111,6 +112,7 @@ class BabelQueue:
111
112
  self.shutdown_timeout = float(shutdown_timeout)
112
113
  self._handlers: Dict[str, Handler] = {}
113
114
  self._stop = threading.Event()
115
+ self._rewind_pause = 1.0 # seconds to wait before a rewound gated message is re-polled
114
116
  self._phase = _IDLE
115
117
  self._last_phase = _IDLE
116
118
  self._signalled = False
@@ -277,7 +279,11 @@ class BabelQueue:
277
279
  with _headers_scope(received.headers), _replay_scope(
278
280
  bool(received.headers.get(HEADER_REPLAY_BYPASS))
279
281
  ):
280
- envelope = EnvelopeCodec.decode(received.body)
282
+ # A version-gated delivery is rejected without decoding its body (broker-bindings
283
+ # §3.7/§4.7/§5.7/§6.7/§7.7): the raw body goes to the DLQ byte-identical, so there is
284
+ # no envelope to read here.
285
+ gated = received.route_urn.startswith(_UNSUPPORTED_SCHEMA_ROUTE_PREFIX)
286
+ envelope = {} if gated else EnvelopeCodec.decode(received.body)
281
287
  urn = str(envelope.get("job") or envelope.get("urn") or "")
282
288
  if received.route_urn and envelope:
283
289
  # Transport-supplied routing override; body untouched. Only for a body that
@@ -295,6 +301,20 @@ class BabelQueue:
295
301
  self._phase = _SETTLING
296
302
  except Exception as exc: # noqa: BLE001 - one bad message must not kill the loop
297
303
  self._phase = _SETTLING
304
+ if received.route_urn.startswith(_UNSUPPORTED_SCHEMA_ROUTE_PREFIX):
305
+ # A version-gated delivery never takes the generic retry path, even when its
306
+ # dead-letter write fails: a retry re-publish may rebuild the version header
307
+ # from the (valid) body and walk the message past the gate into a handler.
308
+ # It is left unacked; the broker (or a rewind, for cumulative-offset brokers
309
+ # such as Kafka) brings it back and the gate rejects it again.
310
+ logger.error(
311
+ "Dead-lettering a version-gated message on queue %r failed; it was NOT "
312
+ "retried or acknowledged; it will come back and the dead-letter write will be retried.",
313
+ received.queue,
314
+ exc_info=exc,
315
+ )
316
+ self._rewind_unsettled(received)
317
+ return
298
318
  self._retry_or_dead_letter(received, envelope, exc)
299
319
  else:
300
320
  # Outside the handler's try: a failed ack is not a handler failure, so it must
@@ -417,6 +437,23 @@ class BabelQueue:
417
437
  exc_info=True,
418
438
  )
419
439
 
440
+ def _rewind_unsettled(self, received: ReceivedMessage) -> None:
441
+ """Make a deliberately unsettled message come back (cumulative-offset brokers such as Kafka
442
+ would otherwise lose it when a later record on the partition is committed), after a short
443
+ pause so an unavailable DLQ is not hammered in a hot loop."""
444
+ if not isinstance(self.transport, Rewinder):
445
+ return
446
+ try:
447
+ self.transport.rewind(received)
448
+ except Exception: # noqa: BLE001 - reported, never kills the loop
449
+ logger.error(
450
+ "Failed to rewind an unsettled version-gated message on queue %r.",
451
+ received.queue,
452
+ exc_info=True,
453
+ )
454
+ return
455
+ self._stop.wait(self._rewind_pause)
456
+
420
457
  def _release_unprocessed(self, received: ReceivedMessage) -> None:
421
458
  """Release a message whose handler never completed (shutdown): unchanged body, no
422
459
  backoff, and the runtime adds no ``attempts`` increment. A broker that counts
@@ -440,23 +477,45 @@ class BabelQueue:
440
477
  if strategy == UnknownUrnStrategy.DELETE:
441
478
  self._ack_settled(received)
442
479
  return
443
- # A transport's version-gated delivery, an undecodable body or a body naming no URN can
444
- # never gain a handler, so releasing it unchanged would redeliver it forever. It takes the
445
- # bounded FAIL path instead (retry up to max_attempts, then dead-letter or drop); only a
446
- # genuinely unknown URN keeps the verbatim release.
447
- gated = received.route_urn.startswith(_UNSUPPORTED_SCHEMA_ROUTE_PREFIX)
448
- poison = gated or not urn
449
- if strategy == UnknownUrnStrategy.RELEASE and not poison:
480
+ # A transport's version-gated delivery (unsupported ``bq-schema-version``) can never gain
481
+ # a handler and must not be retried: a retry re-publishes through paths that may rebuild
482
+ # the version header from the (valid) body, or never advance the attempt counter, so the
483
+ # gate would be bypassed or the message would loop. It goes straight to the DLQ (or is
484
+ # dropped when no DLQ is configured).
485
+ if received.route_urn.startswith(_UNSUPPORTED_SCHEMA_ROUTE_PREFIX):
486
+ self._settle_version_gated(received)
487
+ return
488
+ # An undecodable body or a body naming no URN can never gain a handler either, so
489
+ # releasing it unchanged would redeliver it forever. It takes the bounded FAIL path
490
+ # instead (retry up to max_attempts, then dead-letter or drop); only a genuinely unknown
491
+ # URN keeps the verbatim release.
492
+ if strategy == UnknownUrnStrategy.RELEASE and urn:
450
493
  self._release(received, received.body, self.unknown_urn_release_delay)
451
494
  return
452
495
  if strategy == UnknownUrnStrategy.DEAD_LETTER:
453
496
  self._dead_letter(received, dict(envelope), "unknown_urn", None)
454
497
  return
455
498
  # FAIL (and RELEASE of a poison message) — surfaced through the retry/dead-letter path
456
- # (never kills the loop). A gated delivery names its reserved route (the rejected version).
457
- raise UnknownUrnError(
458
- f"No handler mapped for URN [{urn or (received.route_urn if gated else '') or '(empty)'}]."
459
- )
499
+ # (never kills the loop).
500
+ raise UnknownUrnError(f"No handler mapped for URN [{urn or '(empty)'}].")
501
+
502
+ def _dead_letter_target(self, received: ReceivedMessage) -> str:
503
+ """The queue a dead-lettered delivery is written to (configured DLQ or ``<queue><suffix>``)."""
504
+ return self.dead_letter_queue or (received.queue + self.dead_letter_suffix)
505
+
506
+ def _settle_version_gated(self, received: ReceivedMessage) -> None:
507
+ """Dead-letter a version-gated delivery directly (no retry); drop it when no DLQ is set.
508
+
509
+ The gate rejects the message *without decoding it*, so the DLQ record is the incoming raw
510
+ body, byte-identical, with no ``dead_letter`` block (matching the Java, .NET and Node
511
+ SDKs). A failed DLQ write propagates: the delivery is left unacknowledged and handed back
512
+ (see :meth:`dispatch`), never retried.
513
+ """
514
+ if self.dead_letter_enabled or self.on_unknown_urn == UnknownUrnStrategy.DEAD_LETTER:
515
+ self._publish_for(received, self._dead_letter_target(received), received.body)
516
+ self._ack_settled(received)
517
+ return
518
+ self._ack_settled(received)
460
519
 
461
520
  def _retry_or_dead_letter(
462
521
  self, received: ReceivedMessage, envelope: Dict[str, Any], exc: BaseException
@@ -492,15 +551,17 @@ class BabelQueue:
492
551
  error=(str(exc) if exc is not None else None),
493
552
  exception=(type(exc).__name__ if exc is not None else None),
494
553
  )
495
- target = self.dead_letter_queue or (received.queue + self.dead_letter_suffix)
496
- self._publish_for(received, target, EnvelopeCodec.encode(annotated))
554
+ self._publish_for(
555
+ received, self._dead_letter_target(received), EnvelopeCodec.encode(annotated)
556
+ )
497
557
  self._ack_settled(received)
498
558
 
499
559
 
500
560
  #: Reserved ``ReceivedMessage.route_urn`` prefix a transport uses to hand over a delivery it
501
561
  #: version-gated (broker-bindings §5.7 step 3, e.g. the pulsar transport's ``bq-schema-version``
502
562
  #: check). Not a ``urn:babel:`` URN, so no handler owns it. Must equal
503
- #: ``pulsar_transport.UNSUPPORTED_SCHEMA_ROUTE_PREFIX`` (locked by a test); never reaches the wire.
563
+ #: ``UNSUPPORTED_SCHEMA_ROUTE_PREFIX`` of the pulsar, sqs, asb, kafka and artemis transports (locked
564
+ #: by a test); never reaches the wire. A delivery under it is dead-lettered directly, never retried.
504
565
  _UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
505
566
 
506
567
 
@@ -32,10 +32,25 @@ transport directly and pass it via ``BabelQueue(transport=...)`` or
32
32
 
33
33
  from __future__ import annotations
34
34
 
35
+ import logging
36
+ import re
35
37
  from typing import Any, Dict, Optional
38
+ from urllib.parse import quote
36
39
 
37
- from .codec import EnvelopeCodec, parse_envelope
38
- from .transport import ReceivedMessage, Transport
40
+ from .codec import SCHEMA_VERSION, EnvelopeCodec, parse_envelope
41
+ from .transport import ReceivedMessage, Transport, truncate_declared_version
42
+
43
+ _logger = logging.getLogger("babelqueue.artemis")
44
+
45
+ #: A blank ``bq_schema_version``: empty or only ASCII whitespace (space, \t, \n, \v, \f, \r) — the
46
+ #: same set in every SDK (GR-5). Deliberately not ``str.strip()``, which also strips U+001C-U+001F,
47
+ #: U+0085, U+00A0, U+2028, U+3000 ...
48
+ _BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
49
+ #: Prefix of the reserved ``route_urn`` a version-gated message is delivered under. It is not a
50
+ #: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime dead-letters it
51
+ #: directly (never retried, never decoded; the DLQ record is the incoming raw body). The
52
+ #: rejected ``bq_schema_version`` is named in the logged error.
53
+ UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
39
54
 
40
55
  JMS_TYPE_ANNOTATION = "x-opt-jms-type"
41
56
  APP_ID = "babelqueue"
@@ -187,6 +202,33 @@ class ArtemisTransport(Transport):
187
202
  except (TypeError, ValueError): # pragma: no cover - defensive
188
203
  return 0
189
204
 
205
+ @staticmethod
206
+ def _unsupported_schema_version(properties: Any) -> Optional[str]:
207
+ """The §7.7 step-3 gate on the ``bq_schema_version`` application property (underscores —
208
+ ADR-0017): the offending raw value, or ``None``.
209
+
210
+ A missing property, a null value or a blank value (the empty string or only ASCII
211
+ whitespace — the same set in every SDK, GR-5, not ``str.strip()``) is not gated: the usual
212
+ decode path runs. A ``bytes`` value is decoded as UTF-8. A present value is accepted only
213
+ when it is exactly the core's supported ``SCHEMA_VERSION``: the string ``"1"`` or the
214
+ integer ``1`` (a ``bool`` is not an integer here, though it is one in Python); ``"2"``,
215
+ ``"x"``, ``"01"``, ``" 1"``, ``0``, ``2``, ``1.5``, ``True`` and any other type are
216
+ rejected."""
217
+ if not isinstance(properties, dict):
218
+ return None
219
+ raw = properties.get("bq_schema_version")
220
+ if raw is None:
221
+ return None
222
+ if isinstance(raw, (bytes, bytearray)):
223
+ raw = bytes(raw).decode("utf-8", "replace")
224
+ if isinstance(raw, str):
225
+ if _BLANK_ASCII_PATTERN.fullmatch(raw) is not None or raw == str(SCHEMA_VERSION):
226
+ return None
227
+ return raw
228
+ if isinstance(raw, int) and not isinstance(raw, bool) and raw == SCHEMA_VERSION:
229
+ return None
230
+ return str(raw)
231
+
190
232
  @staticmethod
191
233
  def _payload(message: Any) -> str:
192
234
  body = getattr(message, "body", None)
@@ -212,6 +254,26 @@ class ArtemisTransport(Transport):
212
254
  raise
213
255
  if message is None: # pragma: no cover - defensive (real client raises instead)
214
256
  return None
257
+ # §7.7 step 3: gate on the ``bq_schema_version`` property BEFORE any body decode (the
258
+ # attempts reconcile below decodes the body). A rejected message keeps its raw body and the
259
+ # reserved route; the runtime then dead-letters it directly (no retry).
260
+ unsupported = truncate_declared_version(
261
+ self._unsupported_schema_version(getattr(message, "properties", None))
262
+ )
263
+ if unsupported is not None:
264
+ _logger.warning(
265
+ "Artemis message on %r carries unsupported bq_schema_version %r (supported: %s); "
266
+ "not decoding it.",
267
+ queue,
268
+ unsupported,
269
+ SCHEMA_VERSION,
270
+ )
271
+ return ReceivedMessage(
272
+ body=self._payload(message),
273
+ queue=queue,
274
+ handle=receiver,
275
+ route_urn=UNSUPPORTED_SCHEMA_ROUTE_PREFIX + quote(unsupported, safe=""),
276
+ )
215
277
  body = self._reconcile(self._payload(message), self._delivery_count(message))
216
278
  return ReceivedMessage(body=body, queue=queue, handle=receiver)
217
279
 
@@ -23,11 +23,25 @@ transport directly and pass it via ``BabelQueue(transport=...)`` or
23
23
 
24
24
  from __future__ import annotations
25
25
 
26
+ import logging
27
+ import re
26
28
  from typing import Any, Dict, Optional
27
- from urllib.parse import urlsplit
29
+ from urllib.parse import quote, urlsplit
28
30
 
29
- from .codec import EnvelopeCodec, parse_envelope
30
- from .transport import ReceivedMessage, Transport
31
+ from .codec import SCHEMA_VERSION, EnvelopeCodec, parse_envelope
32
+ from .transport import ReceivedMessage, Transport, truncate_declared_version
33
+
34
+ _logger = logging.getLogger("babelqueue.asb")
35
+
36
+ #: A blank ``bq-schema-version``: empty or only ASCII whitespace (space, \t, \n, \v, \f, \r) — the
37
+ #: same set in every SDK (GR-5). Deliberately not ``str.strip()``, which also strips U+001C-U+001F,
38
+ #: U+0085, U+00A0, U+2028, U+3000 ...
39
+ _BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
40
+ #: Prefix of the reserved ``route_urn`` a version-gated message is delivered under. It is not a
41
+ #: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime dead-letters it
42
+ #: directly (never retried, never decoded; the DLQ record is the incoming raw body). The
43
+ #: rejected ``bq-schema-version`` is named in the logged error.
44
+ UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
31
45
 
32
46
 
33
47
  class AsbTransport(Transport):
@@ -141,6 +155,42 @@ class AsbTransport(Transport):
141
155
  env["attempts"] = native
142
156
  return EnvelopeCodec.encode(env)
143
157
 
158
+ @staticmethod
159
+ def _unsupported_schema_version(properties: Any) -> Optional[str]:
160
+ """The §4.7 step-3 gate on the ``bq-schema-version`` application property: the offending
161
+ raw value as text, or ``None``.
162
+
163
+ The AMQP client may hand keys and values over as ``bytes``; both are decoded as UTF-8. A
164
+ missing property, or a blank string (the empty string or only ASCII whitespace — the same
165
+ set in every SDK, GR-5, not ``str.strip()``) is not gated: the usual decode path runs. A
166
+ present value is accepted only when it is exactly the core's supported ``SCHEMA_VERSION``:
167
+ the string ``"1"`` or the integer ``1`` (a ``bool`` is not an integer here, though it is
168
+ one in Python). ``"2"``, ``"x"``, ``"01"``, ``" 1"``, ``0``, ``2``, ``1.5``, ``True`` and
169
+ any other type are rejected."""
170
+ if not properties:
171
+ return None
172
+ raw: Any = None
173
+ try:
174
+ items = list(properties.items())
175
+ except (AttributeError, TypeError):
176
+ return None
177
+ for key, value in items:
178
+ name = key.decode("utf-8", "replace") if isinstance(key, (bytes, bytearray)) else key
179
+ if name == "bq-schema-version":
180
+ raw = value
181
+ break
182
+ if raw is None:
183
+ return None
184
+ if isinstance(raw, (bytes, bytearray)):
185
+ raw = bytes(raw).decode("utf-8", "replace")
186
+ if isinstance(raw, str):
187
+ if _BLANK_ASCII_PATTERN.fullmatch(raw) is not None or raw == str(SCHEMA_VERSION):
188
+ return None
189
+ return raw
190
+ if isinstance(raw, int) and not isinstance(raw, bool) and raw == SCHEMA_VERSION:
191
+ return None
192
+ return str(raw)
193
+
144
194
  # -- Transport ----------------------------------------------------------
145
195
 
146
196
  def publish(self, queue: str, body: str) -> None:
@@ -157,6 +207,27 @@ class AsbTransport(Transport):
157
207
  if not messages:
158
208
  return None
159
209
  message = messages[0]
210
+ # §4.7 step 3: gate on ``bq-schema-version`` BEFORE any body decode (the attempts reconcile
211
+ # below decodes the body). A rejected message keeps its raw body and the reserved route.
212
+ unsupported = truncate_declared_version(
213
+ self._unsupported_schema_version(
214
+ getattr(message, "application_properties", None)
215
+ )
216
+ )
217
+ if unsupported is not None:
218
+ _logger.warning(
219
+ "Azure Service Bus message on %r carries unsupported bq-schema-version %r "
220
+ "(supported: %s); not decoding it.",
221
+ queue,
222
+ unsupported,
223
+ SCHEMA_VERSION,
224
+ )
225
+ return ReceivedMessage(
226
+ body=str(message),
227
+ queue=queue,
228
+ handle=message,
229
+ route_urn=UNSUPPORTED_SCHEMA_ROUTE_PREFIX + quote(unsupported, safe=""),
230
+ )
160
231
  body = self._reconcile(str(message), getattr(message, "delivery_count", None))
161
232
  return ReceivedMessage(body=body, queue=queue, handle=message)
162
233