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.
- {babelqueue-1.15.0 → babelqueue-1.16.1}/.github/workflows/ci.yml +56 -8
- {babelqueue-1.15.0 → babelqueue-1.16.1}/.github/workflows/release.yml +3 -3
- {babelqueue-1.15.0 → babelqueue-1.16.1}/CHANGELOG.md +55 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/PKG-INFO +38 -1
- {babelqueue-1.15.0 → babelqueue-1.16.1}/README.md +37 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/pyproject.toml +1 -1
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/__init__.py +3 -1
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/app.py +76 -15
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/artemis_transport.py +64 -2
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/asb_transport.py +74 -3
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/kafka_transport.py +134 -5
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/pulsar_transport.py +8 -7
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/sqs_transport.py +62 -5
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/transport.py +28 -0
- babelqueue-1.16.1/tests/conformance/CONFORMANCE_VERSION +1 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/manifest.json +133 -0
- babelqueue-1.16.1/tests/fakes/confluent_kafka/__init__.py +73 -0
- babelqueue-1.16.1/tests/fakes/confluent_kafka/admin.py +20 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_app.py +103 -20
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_artemis_conformance.py +27 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_artemis_transport.py +132 -2
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_asb_conformance.py +30 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_asb_transport.py +138 -2
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_kafka_conformance.py +29 -0
- babelqueue-1.16.1/tests/test_kafka_integration.py +169 -0
- babelqueue-1.16.1/tests/test_kafka_integration_logic.py +44 -0
- babelqueue-1.16.1/tests/test_kafka_transport.py +592 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_pulsar_conformance.py +27 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_pulsar_transport.py +13 -8
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_sqs_conformance.py +27 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_sqs_transport.py +159 -0
- babelqueue-1.15.0/tests/conformance/CONFORMANCE_VERSION +0 -1
- babelqueue-1.15.0/tests/test_kafka_transport.py +0 -194
- {babelqueue-1.15.0 → babelqueue-1.16.1}/.github/FUNDING.yml +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/.github/dependabot.yml +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/.gitignore +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/LICENSE +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/gdpr.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/headers.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/idempotency.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/otel.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/outbox.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/redis_transport.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/redrive.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/replay.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/src/babelqueue/schema.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/data-array-rejected.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/unknown-lang.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_behaviour_conformance.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_celery.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_codec.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_conformance.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_django.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_gdpr.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_headers.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_idempotency.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_otel.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_outbox.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_overhead.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_redis_transport.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_redrive.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_replay.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_schema.py +0 -0
- {babelqueue-1.15.0 → babelqueue-1.16.1}/tests/test_schema_conformance.py +0 -0
- {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@
|
|
21
|
+
- uses: actions/checkout@v7
|
|
21
22
|
|
|
22
23
|
- name: Setup Python
|
|
23
|
-
uses: actions/setup-python@
|
|
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@
|
|
40
|
+
- uses: actions/checkout@v7
|
|
40
41
|
- name: Setup Python
|
|
41
|
-
uses: actions/setup-python@
|
|
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@
|
|
83
|
+
- uses: actions/checkout@v7
|
|
83
84
|
|
|
84
85
|
- name: Setup Python
|
|
85
|
-
uses: actions/setup-python@
|
|
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@
|
|
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.
|
|
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@
|
|
21
|
+
- uses: actions/checkout@v7
|
|
22
22
|
|
|
23
23
|
- name: Setup Python
|
|
24
|
-
uses: actions/setup-python@
|
|
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@
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
444
|
-
#
|
|
445
|
-
#
|
|
446
|
-
#
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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).
|
|
457
|
-
raise UnknownUrnError(
|
|
458
|
-
|
|
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
|
-
|
|
496
|
-
|
|
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
|
-
#: ``
|
|
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
|
|