babelqueue 1.22.0__tar.gz → 1.23.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {babelqueue-1.22.0 → babelqueue-1.23.0}/.github/workflows/ci.yml +1 -1
- {babelqueue-1.22.0 → babelqueue-1.23.0}/.github/workflows/release.yml +2 -2
- {babelqueue-1.22.0 → babelqueue-1.23.0}/CHANGELOG.md +70 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/PKG-INFO +26 -4
- {babelqueue-1.22.0 → babelqueue-1.23.0}/README.md +25 -3
- {babelqueue-1.22.0 → babelqueue-1.23.0}/pyproject.toml +1 -1
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/__init__.py +1 -1
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/_envelope_shape.py +1 -1
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/app.py +117 -18
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/artemis_transport.py +2 -1
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/codec.py +46 -1
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/kafka_transport.py +8 -2
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/pika_transport.py +3 -1
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/pulsar_transport.py +35 -2
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/redis_transport.py +23 -13
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/sqs_transport.py +11 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/transport.py +14 -0
- babelqueue-1.23.0/tests/conformance/CONFORMANCE_VERSION +1 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/manifest.json +818 -1
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/schema/results.example.json +1 -1
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_app.py +23 -4
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_behaviour_conformance.py +176 -3
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_conformance.py +2 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_kafka_retry.py +56 -0
- babelqueue-1.23.0/tests/test_poison_routing.py +511 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_pulsar_transport.py +4 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_redis_transport.py +92 -5
- babelqueue-1.22.0/tests/conformance/CONFORMANCE_VERSION +0 -1
- {babelqueue-1.22.0 → babelqueue-1.23.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/.github/dependabot.yml +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/.gitignore +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/LICENSE +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/conformance-known-gaps.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/scripts/check_conformance_results.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/_poison_record.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/asb_transport.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/gdpr.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/headers.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/idempotency.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/kafka_retry.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/otel.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/outbox.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/redrive.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/replay.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/src/babelqueue/schema.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/blank-ascii-job-urn-fallback.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/dead-letter-roundtrip.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/duplicate-keys-last-wins.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-job-and-urn.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-job-nonascii-space-and-urn.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/invalid-wrong-case-job.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-array-urn-fallback.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-bool-urn-fallback.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-empty-string-urn-fallback.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-missing-job-array-urn.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-null-no-urn.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-null-urn-fallback.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-number-blank-urn.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-number-nonstring-urn.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-number-urn-fallback.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-object-urn-fallback.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/meta-ext-vendor-roundtrip.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-cr.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-ff.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-lf.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-space.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-tab.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-vt.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-cr.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-ff.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-lf.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-space.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-tab.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-vt.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-job-and-urn-both-blank.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-reemit-job-ascii-padded.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-reemit-job-unicode-padded.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-reemit-trace-id-padded.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-unicode-job-emsp.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-unicode-job-ideographic.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-unicode-job-nbsp.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-urn-only-ascii-blank.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-urn-only-unicode-space.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/urn-only-roundtrip.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/fixtures/wrong-case-keys-roundtrip.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/conformance/schema/results.schema.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/fakes/confluent_kafka/__init__.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/fakes/confluent_kafka/admin.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/redis_backend.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_artemis_transport.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_asb_transport.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_celery.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_codec.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_django.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_gdpr.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_headers.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_idempotency.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_kafka_integration.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_kafka_integration_logic.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_kafka_transport.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_otel.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_outbox.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_redrive.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_reencode_regression.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_replay.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_schema.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_schema_conformance.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_shutdown.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.0}/tests/test_sqs_conformance.py +0 -0
- {babelqueue-1.22.0 → babelqueue-1.23.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.
|
|
180
|
+
BQ_CONFORMANCE_VERSION: 1.7.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
|
|
@@ -14,7 +14,7 @@ jobs:
|
|
|
14
14
|
# Recommended for PyPI Trusted Publishing — configure a "pypi" environment +
|
|
15
15
|
# a trusted publisher on PyPI (repo BabelQueue/babelqueue-python, this workflow).
|
|
16
16
|
environment: pypi
|
|
17
|
-
# redis_layout (conformance 1.
|
|
17
|
+
# redis_layout (conformance 1.7.0) drives the real Lua scripts: a real redis:7 is mandatory,
|
|
18
18
|
# exactly as in ci.yml's `conformance` job — without it the cases are reported as failures.
|
|
19
19
|
services:
|
|
20
20
|
redis:
|
|
@@ -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.
|
|
31
|
+
BQ_CONFORMANCE_VERSION: 1.7.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,76 @@ The envelope wire format is versioned separately by `meta.schema_version`
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [1.23.0] - 2026-10-25
|
|
13
|
+
|
|
14
|
+
Implements ADR-0036 (post-R1 parity) decisions 2, 4, 5 and 6. A MINOR: every change is a
|
|
15
|
+
consumer-side settle decision, so the envelope stays at `schema_version: 1`. The vendored
|
|
16
|
+
conformance suite moves to v1.7.0 (new sections `identity_verdict` and `pulsar_exhaustion`, both
|
|
17
|
+
passing; `conformance-known-gaps.json` stays empty).
|
|
18
|
+
|
|
19
|
+
### Changed (behaviour)
|
|
20
|
+
- **A body that does not decode, or has no identity, is poison on every transport (ADR-0036
|
|
21
|
+
decisions 4 and 6).** A JSON object that has neither a valid string `job` nor a valid string
|
|
22
|
+
`urn` (ADR-0032 section 6) used to be an *unknown URN*, and on SQS, AMQP, Pulsar, Azure Service
|
|
23
|
+
Bus, Artemis and the memory transport a body that did not decode was decoded leniently into an
|
|
24
|
+
empty envelope and retried as an unknown URN, finally dead-lettered as `reason: "unknown_urn"`
|
|
25
|
+
without the original body. Both are now **poison** and take the poison route of Go's
|
|
26
|
+
`routePoison`: they are never handed to a handler and never take the unknown-URN strategies
|
|
27
|
+
(those remain for a valid envelope whose URN has no handler). With the DLQ on (`dead_letter=True`
|
|
28
|
+
or `on_unknown_urn=DEAD_LETTER`) the message is written to the DLQ at once, with no retry, as the
|
|
29
|
+
ADR-0035 record (`dead_letter.reason: "poison"`, the original body preserved: a strict JSON
|
|
30
|
+
object spliced byte for byte, anything else as `raw`, base64 plus `raw_encoding` when it is not
|
|
31
|
+
valid UTF-8) and acknowledged after the write; `on_unknown_urn=DELETE` drops it (logged); with
|
|
32
|
+
the DLQ off `RELEASE` is replaced by the bounded `FAIL` path (re-published with `attempts + 1`
|
|
33
|
+
up to `max_attempts`, then dropped and logged). An operator who relied on a poison body being
|
|
34
|
+
retried through the unknown-URN strategy, or on a `reason: "unknown_urn"` record for it, sees the
|
|
35
|
+
new behaviour. The classification is `EnvelopeCodec.poison_cause(raw)` (new, pure): the typed
|
|
36
|
+
decode of the Go reference plus the identity rule; `decode` / `decode_lenient` are unchanged.
|
|
37
|
+
- **Pulsar poison records carry `reason: "poison"`** (they were `unknown_urn`). The Pulsar
|
|
38
|
+
transport also reports the reconciled `bq-attempts` / redelivery count to the runtime
|
|
39
|
+
(`ReceivedMessage.native_attempts`, new), as does SQS (`ApproximateReceiveCount - 1`), so a poison
|
|
40
|
+
body, which cannot carry `attempts`, is still bounded by `max_attempts`.
|
|
41
|
+
- **Redis: an identity-less body is dead-lettered unconditionally (ADR-0036 decision 4,
|
|
42
|
+
broker-bindings section 1.1 "Identity-less body").** In both layouts (legacy `:processing`
|
|
43
|
+
and Laravel-compatible zset) a body that decodes but names no identity (including a bare `null`,
|
|
44
|
+
which used to follow the bounded `FAIL` path) is handed over as transport poison: it is written
|
|
45
|
+
to `<queue>.dlq` as the ADR-0035 record with `reason: "poison"` in every configuration
|
|
46
|
+
(`on_unknown_urn=DELETE` and dead-lettering off change nothing), never handed to a handler and
|
|
47
|
+
never retried. Nothing else in the Redis transport changes.
|
|
48
|
+
- **Kafka without retry tiers: a poison record no longer blocks the partition (ADR-0036 decision
|
|
49
|
+
5, broker-bindings section 6.5 degrade-to-delete).** With no `retry_tiers_ms`, dead-lettering off
|
|
50
|
+
and `on_unknown_urn` not `DELETE`, the bounded retry of a poison record (including an
|
|
51
|
+
identity-less body) needs a tier and raised, leaving the record uncommitted with `bq-attempts`
|
|
52
|
+
never advancing. It is now committed, dropped and logged, and the partition advances. A failing
|
|
53
|
+
handler and `RELEASE` of a valid envelope keep the section 6.5 rule (they still raise
|
|
54
|
+
`BabelQueueError` without tiers). The start-up warning for a tierless transport now says so; enable
|
|
55
|
+
`dead_letter` to keep such records in `<topic>.dlq`.
|
|
56
|
+
- A message dropped after its retries are exhausted with no DLQ configured is now logged
|
|
57
|
+
(`babelqueue.app`, WARNING) instead of being dropped silently.
|
|
58
|
+
|
|
59
|
+
- **Pulsar: a failed DLQ write or retry publish is negatively acknowledged (Go `Rewind`).**
|
|
60
|
+
`PulsarTransport` is now a `Rewinder` (`negative_acknowledge`), so the unsettled original is
|
|
61
|
+
redelivered by the broker instead of staying un-acknowledged for the life of the consumer. A
|
|
62
|
+
graceful-shutdown release of an in-flight Pulsar message is now a nack as well rather than a
|
|
63
|
+
re-publish (on a `Shared` subscription the native redelivery count advances; on `Exclusive` /
|
|
64
|
+
`Failover` it does not, which is harmless: `bq-attempts` carries the attempt limit).
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
- **Bytes that are not valid UTF-8 no longer crash the AMQP, Pulsar and Artemis consumers.** They
|
|
68
|
+
were decoded strictly in `pop`; they are now kept losslessly (`surrogateescape`). Invalid UTF-8
|
|
69
|
+
is poison wherever it sits, **also inside a JSON string** (`EnvelopeCodec.poison_cause`,
|
|
70
|
+
error-handling section 7): it is never handed to a handler and is dead-lettered as the ADR-0035
|
|
71
|
+
base64 record, like on Redis and Kafka.
|
|
72
|
+
|
|
73
|
+
### Added
|
|
74
|
+
- `EnvelopeCodec.poison_cause(raw, *, require_identity=True)`; `Transport.classifies_poison`
|
|
75
|
+
(Kafka and Redis classify poison themselves and opt out of the runtime's classification);
|
|
76
|
+
`ReceivedMessage.native_attempts`.
|
|
77
|
+
- Conformance runners for `identity_verdict` (the core verdict on all 97 bodies, plus the fate on
|
|
78
|
+
the memory transport) and `pulsar_exhaustion` (all 29 rows, driven through the real
|
|
79
|
+
`PulsarTransport` and runtime over a duck-typed client), and per-transport poison tests.
|
|
80
|
+
- `ci.yml` / `release.yml` pin conformance `1.7.0` (the `fetch.sh` SHA-256 is unchanged).
|
|
81
|
+
|
|
12
82
|
## [1.22.0] - 2026-10-24
|
|
13
83
|
|
|
14
84
|
Implements ADR-0033 (Redis) and ADR-0034 (Kafka tiers, backoff, poison). The envelope stays at
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: babelqueue
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.23.0
|
|
4
4
|
Summary: Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers.
|
|
5
5
|
Project-URL: Homepage, https://babelqueue.com
|
|
6
6
|
Project-URL: Source, https://github.com/BabelQueue/babelqueue-python
|
|
@@ -313,9 +313,31 @@ the DLQ on (`dead_letter` or `on_unknown_urn=DEAD_LETTER`) it is dead-lettered o
|
|
|
313
313
|
written to `<topic>.dlq` (which must exist) and its offset is committed once that write
|
|
314
314
|
is confirmed; a failed write leaves it uncommitted and rewound. With the DLQ off nothing is written:
|
|
315
315
|
`on_unknown_urn=DELETE` drops it, any other strategy retries it through the retry tiers up to
|
|
316
|
-
`max_attempts` and then drops it
|
|
317
|
-
the
|
|
318
|
-
|
|
316
|
+
`max_attempts` and then drops it. **Without retry tiers** that bounded retry has no tier to use, so
|
|
317
|
+
(with the DLQ off and `on_unknown_urn` not `DELETE`) the poison record is committed, dropped and logged
|
|
318
|
+
instead of blocking the partition (section 6.5 degrade-to-delete, ADR-0036 decision 5); enable
|
|
319
|
+
`dead_letter` to keep it in `<topic>.dlq`. A failing handler without tiers still cannot be retried, as above.
|
|
320
|
+
|
|
321
|
+
### Poison messages on every other transport
|
|
322
|
+
|
|
323
|
+
A message body that does not decode into an envelope (not a JSON object, malformed, a wrongly typed
|
|
324
|
+
member, or bytes that are not valid UTF-8, even inside a JSON string) or that has neither a valid
|
|
325
|
+
string `job` nor a valid string `urn` is **poison** on every transport (ADR-0036 decisions 2, 4 and
|
|
326
|
+
6; conformance `identity_verdict`). It is never handed to a handler and never takes the
|
|
327
|
+
unknown-URN strategies, which are for a valid envelope whose URN has no handler:
|
|
328
|
+
|
|
329
|
+
- DLQ on (`dead_letter` or `on_unknown_urn=DEAD_LETTER`): written to `<queue>.dlq` at once, with no
|
|
330
|
+
retry, as the ADR-0035 record (`dead_letter.reason: "poison"`, the original body preserved,
|
|
331
|
+
base64 plus `raw_encoding` when it is not UTF-8), then acknowledged. A failed write leaves it
|
|
332
|
+
unacknowledged.
|
|
333
|
+
- `on_unknown_urn=DELETE`: acknowledged and dropped (logged).
|
|
334
|
+
- DLQ off: the bounded `FAIL` path (`RELEASE` is replaced by it): retried up to `max_attempts`, then
|
|
335
|
+
dropped and logged. The count comes from the broker where it has one (SQS receive count, Pulsar
|
|
336
|
+
`bq-attempts` / redelivery count), otherwise from a bare `{"attempts": n}` body the retry leaves.
|
|
337
|
+
|
|
338
|
+
On Redis (both layouts) an identity-less body is dead-lettered as poison in **every** configuration,
|
|
339
|
+
including `on_unknown_urn=DELETE` and dead-lettering off (broker-bindings section 1.1). On the legacy
|
|
340
|
+
(`<queue>:processing`) layout a body that Go's typed decode rejects is dead-lettered raw on its first
|
|
319
341
|
delivery and removed from `:processing`.
|
|
320
342
|
|
|
321
343
|
### Sharing a Redis queue with Laravel
|
|
@@ -259,9 +259,31 @@ the DLQ on (`dead_letter` or `on_unknown_urn=DEAD_LETTER`) it is dead-lettered o
|
|
|
259
259
|
written to `<topic>.dlq` (which must exist) and its offset is committed once that write
|
|
260
260
|
is confirmed; a failed write leaves it uncommitted and rewound. With the DLQ off nothing is written:
|
|
261
261
|
`on_unknown_urn=DELETE` drops it, any other strategy retries it through the retry tiers up to
|
|
262
|
-
`max_attempts` and then drops it
|
|
263
|
-
the
|
|
264
|
-
|
|
262
|
+
`max_attempts` and then drops it. **Without retry tiers** that bounded retry has no tier to use, so
|
|
263
|
+
(with the DLQ off and `on_unknown_urn` not `DELETE`) the poison record is committed, dropped and logged
|
|
264
|
+
instead of blocking the partition (section 6.5 degrade-to-delete, ADR-0036 decision 5); enable
|
|
265
|
+
`dead_letter` to keep it in `<topic>.dlq`. A failing handler without tiers still cannot be retried, as above.
|
|
266
|
+
|
|
267
|
+
### Poison messages on every other transport
|
|
268
|
+
|
|
269
|
+
A message body that does not decode into an envelope (not a JSON object, malformed, a wrongly typed
|
|
270
|
+
member, or bytes that are not valid UTF-8, even inside a JSON string) or that has neither a valid
|
|
271
|
+
string `job` nor a valid string `urn` is **poison** on every transport (ADR-0036 decisions 2, 4 and
|
|
272
|
+
6; conformance `identity_verdict`). It is never handed to a handler and never takes the
|
|
273
|
+
unknown-URN strategies, which are for a valid envelope whose URN has no handler:
|
|
274
|
+
|
|
275
|
+
- DLQ on (`dead_letter` or `on_unknown_urn=DEAD_LETTER`): written to `<queue>.dlq` at once, with no
|
|
276
|
+
retry, as the ADR-0035 record (`dead_letter.reason: "poison"`, the original body preserved,
|
|
277
|
+
base64 plus `raw_encoding` when it is not UTF-8), then acknowledged. A failed write leaves it
|
|
278
|
+
unacknowledged.
|
|
279
|
+
- `on_unknown_urn=DELETE`: acknowledged and dropped (logged).
|
|
280
|
+
- DLQ off: the bounded `FAIL` path (`RELEASE` is replaced by it): retried up to `max_attempts`, then
|
|
281
|
+
dropped and logged. The count comes from the broker where it has one (SQS receive count, Pulsar
|
|
282
|
+
`bq-attempts` / redelivery count), otherwise from a bare `{"attempts": n}` body the retry leaves.
|
|
283
|
+
|
|
284
|
+
On Redis (both layouts) an identity-less body is dead-lettered as poison in **every** configuration,
|
|
285
|
+
including `on_unknown_urn=DELETE` and dead-lettering off (broker-bindings section 1.1). On the legacy
|
|
286
|
+
(`<queue>:processing`) layout a body that Go's typed decode rejects is dead-lettered raw on its first
|
|
265
287
|
delivery and removed from `:processing`.
|
|
266
288
|
|
|
267
289
|
### Sharing a Redis queue with Laravel
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "babelqueue"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.23.0"
|
|
8
8
|
description = "Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -15,7 +15,7 @@ Go reference's ``babelqueue.Decode``, except that member names are case-sensitiv
|
|
|
15
15
|
decoder read them) and every occurrence of a duplicated key is checked; any other member (a
|
|
16
16
|
differently-cased name or a non-string ``urn`` alias included) is only checked for syntax.
|
|
17
17
|
A non-string ``job`` is **not** poison either: the identity resolver ignores it (ADR-0032 section 6),
|
|
18
|
-
so the body is readable and simply carries no identity (conformance ``kafka_poison``, v1.6.0).
|
|
18
|
+
so the body is readable and simply carries no identity (conformance ``kafka_poison``, v1.6.0, and ``identity_verdict``, v1.7.0).
|
|
19
19
|
|
|
20
20
|
A well-typed but non-conformant envelope is not poison: Go's consume path never calls ``Accepts``.
|
|
21
21
|
"""
|
|
@@ -326,15 +326,37 @@ class BabelQueue:
|
|
|
326
326
|
# §3.7/§4.7/§5.7/§6.7/§7.7): the raw body goes to the DLQ byte-identical, so there is
|
|
327
327
|
# no envelope to read here.
|
|
328
328
|
gated = received.route_urn.startswith(_UNSUPPORTED_SCHEMA_ROUTE_PREFIX)
|
|
329
|
-
|
|
329
|
+
# A body that does not decode, or has no identity, is poison (ADR-0036 decisions 4 and
|
|
330
|
+
# 6): never decoded leniently into an empty envelope, never handed to a handler and
|
|
331
|
+
# never routed as an unknown URN. A transport that already hands its poison over under
|
|
332
|
+
# a poison route (Kafka, Redis) is not classified twice. A delivery that carries an
|
|
333
|
+
# out-of-band routing URN is exempt from the identity half of the rule only.
|
|
334
|
+
poison_cause = (
|
|
335
|
+
None
|
|
336
|
+
if gated or getattr(self.transport, "classifies_poison", False)
|
|
337
|
+
else EnvelopeCodec.poison_cause(
|
|
338
|
+
received.body, require_identity=not received.route_urn
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
envelope = (
|
|
342
|
+
{}
|
|
343
|
+
if gated or poison_cause is not None
|
|
344
|
+
else EnvelopeCodec.decode_lenient(received.body)
|
|
345
|
+
)
|
|
330
346
|
urn = str(envelope.get("job") or envelope.get("urn") or "")
|
|
331
347
|
if received.route_urn and envelope:
|
|
332
348
|
# Transport-supplied routing override; body untouched. Only for a body that
|
|
333
349
|
# decodes: an undecodable one (decode() yields {}) stays on the poison path.
|
|
334
350
|
urn = received.route_urn
|
|
335
|
-
handler =
|
|
351
|
+
handler = (
|
|
352
|
+
self._handlers.get(urn) if urn and poison_cause is None else None
|
|
353
|
+
)
|
|
336
354
|
|
|
337
355
|
try:
|
|
356
|
+
if poison_cause is not None:
|
|
357
|
+
self._phase = _SETTLING
|
|
358
|
+
self._route_body_poison(received, poison_cause)
|
|
359
|
+
return
|
|
338
360
|
if handler is None:
|
|
339
361
|
self._phase = _SETTLING
|
|
340
362
|
self._route_unknown(urn, received, envelope)
|
|
@@ -344,15 +366,19 @@ class BabelQueue:
|
|
|
344
366
|
self._phase = _SETTLING
|
|
345
367
|
except Exception as exc: # noqa: BLE001 - one bad message must not kill the loop
|
|
346
368
|
self._phase = _SETTLING
|
|
347
|
-
if received.route_urn.startswith(
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
#
|
|
351
|
-
#
|
|
352
|
-
#
|
|
369
|
+
if poison_cause is not None or received.route_urn.startswith(
|
|
370
|
+
_UNSUPPORTED_SCHEMA_ROUTE_PREFIX
|
|
371
|
+
):
|
|
372
|
+
# A version-gated or poison delivery never takes the generic retry path, even
|
|
373
|
+
# when its dead-letter write fails: a retry re-publish may rebuild the version
|
|
374
|
+
# header from the (valid) body and walk the message past the gate into a
|
|
375
|
+
# handler, and a poison body has no envelope to retry. It is left unacked; the
|
|
376
|
+
# broker (or a rewind, for cumulative-offset brokers such as Kafka) brings it
|
|
377
|
+
# back and the same decision is taken again.
|
|
353
378
|
logger.error(
|
|
354
|
-
"Dead-lettering a version-gated message (route %r) on queue %r
|
|
355
|
-
"retried or acknowledged; it will come back and the
|
|
379
|
+
"Dead-lettering a version-gated or poison message (route %r) on queue %r "
|
|
380
|
+
"failed; it was NOT retried or acknowledged; it will come back and the "
|
|
381
|
+
"dead-letter write will be retried.",
|
|
356
382
|
received.route_urn,
|
|
357
383
|
received.queue,
|
|
358
384
|
exc_info=exc,
|
|
@@ -579,13 +605,16 @@ class BabelQueue:
|
|
|
579
605
|
through a tier. That only happens once a handler fails, so it is a warning (not a
|
|
580
606
|
start-up error).
|
|
581
607
|
"""
|
|
582
|
-
|
|
583
|
-
if isinstance(tiers, list) and not tiers and self.max_attempts > 1 and isinstance(self.transport, Redeliverer):
|
|
608
|
+
if self._retry_tiers_unavailable() and self.max_attempts > 1:
|
|
584
609
|
logger.warning(
|
|
585
610
|
"The Kafka transport has no retry tiers but max_attempts=%d: a failing handler "
|
|
586
611
|
"cannot be retried -- the record is left uncommitted and redelivered after a "
|
|
587
612
|
"pause, so it keeps failing in place. Configure retry_tiers_ms (or set "
|
|
588
|
-
"max_attempts=1) to retry through the tiers."
|
|
613
|
+
"max_attempts=1) to retry through the tiers. A poison record (a body that does "
|
|
614
|
+
"not decode, or has no identity) is different: with dead-lettering off and "
|
|
615
|
+
"on_unknown_urn not 'delete' it is committed and dropped with a logged warning "
|
|
616
|
+
"(broker-bindings section 6.5, degrade-to-delete), so it never blocks the "
|
|
617
|
+
"partition; enable dead_letter to keep it in <topic>.dlq instead.",
|
|
589
618
|
self.max_attempts,
|
|
590
619
|
)
|
|
591
620
|
|
|
@@ -657,6 +686,19 @@ class BabelQueue:
|
|
|
657
686
|
if self.dead_letter_enabled or self.on_unknown_urn == UnknownUrnStrategy.DEAD_LETTER:
|
|
658
687
|
self._settle_version_gated(received, always=True)
|
|
659
688
|
return
|
|
689
|
+
if self._retry_tiers_unavailable():
|
|
690
|
+
# broker-bindings §6.5 / ADR-0036 decision 5: no retry tier, no dead-lettering and not
|
|
691
|
+
# ``delete`` degrades to ``delete``. The bounded retry of a poison record needs a tier
|
|
692
|
+
# and would raise, leaving the record uncommitted with ``bq-attempts`` never advancing,
|
|
693
|
+
# so the partition would block. Commit, drop, and say so.
|
|
694
|
+
logger.warning(
|
|
695
|
+
"Dropping a poison record on queue %r (route %r): the Kafka transport has no retry "
|
|
696
|
+
"tiers and dead-lettering is off, so it can be neither retried nor dead-lettered "
|
|
697
|
+
"(broker-bindings section 6.5, degrade-to-delete). The offset was committed.",
|
|
698
|
+
received.queue, str(route),
|
|
699
|
+
)
|
|
700
|
+
self._ack_settled(received)
|
|
701
|
+
return
|
|
660
702
|
attempts = int(getattr(route, "attempts", 0) or 0)
|
|
661
703
|
self._retry_or_dead_letter(
|
|
662
704
|
received,
|
|
@@ -664,8 +706,60 @@ class BabelQueue:
|
|
|
664
706
|
UnknownUrnError("The record is poison: it carries no decodable envelope."),
|
|
665
707
|
)
|
|
666
708
|
|
|
667
|
-
def
|
|
668
|
-
"""
|
|
709
|
+
def _route_body_poison(self, received: ReceivedMessage, cause: str) -> None:
|
|
710
|
+
"""Settle a body that does not decode into an envelope, or has no identity, on SQS, AMQP,
|
|
711
|
+
Pulsar, Azure Service Bus, Artemis, the memory transport and any other transport that does
|
|
712
|
+
not classify poison itself (ADR-0036 decisions 2, 4 and 6; Go ``routePoison``).
|
|
713
|
+
|
|
714
|
+
The body is never handed to a handler and never takes the unknown-URN strategies:
|
|
715
|
+
|
|
716
|
+
* ``on_unknown_urn=DELETE`` drops it (acknowledged and logged);
|
|
717
|
+
* with the DLQ on (``dead_letter`` or the DEAD_LETTER strategy) it is written to the DLQ as
|
|
718
|
+
the ADR-0035 record with ``reason: "poison"`` (original body preserved) and acknowledged
|
|
719
|
+
after the write, with no retry;
|
|
720
|
+
* with the DLQ off it takes the bounded FAIL path (``RELEASE`` is replaced by it, a poison
|
|
721
|
+
body would otherwise be released forever): re-published with ``attempts + 1`` up to
|
|
722
|
+
``max_attempts``, then dropped. The count is the larger of the transport's own delivery
|
|
723
|
+
count and the ``attempts`` of the bare body a previous retry left behind.
|
|
724
|
+
|
|
725
|
+
A failed DLQ write propagates: :meth:`dispatch` leaves the delivery unacknowledged."""
|
|
726
|
+
if self.on_unknown_urn == UnknownUrnStrategy.DELETE:
|
|
727
|
+
logger.warning(
|
|
728
|
+
"Dropping a poison message on queue %r (on_unknown_urn=delete): %s.",
|
|
729
|
+
received.queue, cause,
|
|
730
|
+
)
|
|
731
|
+
self._ack_settled(received)
|
|
732
|
+
return
|
|
733
|
+
attempts = self._poison_attempts(received)
|
|
734
|
+
if self.dead_letter_enabled or self.on_unknown_urn == UnknownUrnStrategy.DEAD_LETTER:
|
|
735
|
+
self._settle_transport_poison(received, cause, attempts)
|
|
736
|
+
return
|
|
737
|
+
self._retry_or_dead_letter(
|
|
738
|
+
received,
|
|
739
|
+
{"attempts": attempts},
|
|
740
|
+
UnknownUrnError("The message is poison: " + cause),
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
@staticmethod
|
|
744
|
+
def _poison_attempts(received: ReceivedMessage) -> int:
|
|
745
|
+
"""Deliveries a poison body has already had: the larger of the transport's own count and
|
|
746
|
+
the integer ``attempts`` of the body (a bare ``{"attempts": N}`` left by a retry)."""
|
|
747
|
+
body_attempts = parse_envelope(received.body).get("attempts")
|
|
748
|
+
if isinstance(body_attempts, bool) or not isinstance(body_attempts, int):
|
|
749
|
+
body_attempts = 0
|
|
750
|
+
return max(int(received.native_attempts or 0), body_attempts, 0)
|
|
751
|
+
|
|
752
|
+
def _retry_tiers_unavailable(self) -> bool:
|
|
753
|
+
"""Whether the transport is a Kafka transport with no retry tiers configured."""
|
|
754
|
+
tiers = getattr(self.transport, "retry_tiers_ms", None)
|
|
755
|
+
return isinstance(tiers, list) and not tiers and isinstance(self.transport, Redeliverer)
|
|
756
|
+
|
|
757
|
+
def _settle_transport_poison(
|
|
758
|
+
self, received: ReceivedMessage, cause: Optional[str] = None, attempts: int = 0
|
|
759
|
+
) -> None:
|
|
760
|
+
"""Dead-letter a poison delivery (broker-bindings §1.1 / §1.3, ADR-0033, ADR-0035): a Redis
|
|
761
|
+
poison reservation (unconditional) or, via :meth:`_route_body_poison`, a poison body of any
|
|
762
|
+
other transport (``cause`` and ``attempts`` are then known).
|
|
669
763
|
|
|
670
764
|
The DLQ write is an unconditional MUST, so it ignores ``dead_letter_enabled``. The record
|
|
671
765
|
follows ADR-0035 like every other poison record: a strict JSON object body is spliced byte
|
|
@@ -676,8 +770,8 @@ class BabelQueue:
|
|
|
676
770
|
{},
|
|
677
771
|
"poison",
|
|
678
772
|
received.queue,
|
|
679
|
-
|
|
680
|
-
error="Undecodable BabelQueue body",
|
|
773
|
+
attempts,
|
|
774
|
+
error="Undecodable BabelQueue body" + (": " + cause if cause else ""),
|
|
681
775
|
exception=BabelQueueError.__name__,
|
|
682
776
|
)["dead_letter"]
|
|
683
777
|
self._write_dead_letter(
|
|
@@ -730,7 +824,12 @@ class BabelQueue:
|
|
|
730
824
|
self._dead_letter(received, envelope, reason, exc)
|
|
731
825
|
return
|
|
732
826
|
|
|
733
|
-
# Retries exhausted, no DLQ configured — drop it (ack so it leaves the queue).
|
|
827
|
+
# Retries exhausted, no DLQ configured — drop it (ack so it leaves the queue), loudly.
|
|
828
|
+
logger.warning(
|
|
829
|
+
"Dropping a message on queue %r after %d attempts: retries are exhausted and no "
|
|
830
|
+
"dead-letter queue is configured.",
|
|
831
|
+
received.queue, attempts,
|
|
832
|
+
)
|
|
734
833
|
self._ack_settled(received)
|
|
735
834
|
|
|
736
835
|
def _dead_letter(
|
|
@@ -233,7 +233,8 @@ class ArtemisTransport(Transport):
|
|
|
233
233
|
def _payload(message: Any) -> str:
|
|
234
234
|
body = getattr(message, "body", None)
|
|
235
235
|
if isinstance(body, bytes):
|
|
236
|
-
|
|
236
|
+
# Lossless: see ``PulsarTransport._payload`` (ADR-0035 poison record, no crash).
|
|
237
|
+
return body.decode("utf-8", "surrogateescape")
|
|
237
238
|
return str(body) if body is not None else ""
|
|
238
239
|
|
|
239
240
|
# -- Transport ----------------------------------------------------------
|
|
@@ -16,7 +16,7 @@ import time
|
|
|
16
16
|
import uuid
|
|
17
17
|
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
|
18
18
|
|
|
19
|
-
from ._envelope_shape import CORE_META_KEYS
|
|
19
|
+
from ._envelope_shape import CORE_META_KEYS, envelope_violation
|
|
20
20
|
from .exceptions import BabelQueueError, EnvelopeKeyError
|
|
21
21
|
|
|
22
22
|
SCHEMA_VERSION = 1
|
|
@@ -89,6 +89,15 @@ def has_forbidden_keys(envelope: Mapping[str, Any]) -> bool:
|
|
|
89
89
|
return _has_forbidden_keys(envelope)
|
|
90
90
|
|
|
91
91
|
|
|
92
|
+
def has_invalid_utf8(text: str) -> bool:
|
|
93
|
+
"""Whether ``text`` carries a byte that was not valid UTF-8 when the transport read it.
|
|
94
|
+
|
|
95
|
+
Transports read a body losslessly (``errors="surrogateescape"``), so every invalid byte
|
|
96
|
+
appears as a lone surrogate in ``U+DC80..U+DCFF``. A ``\\udcff`` *escape* in the JSON text is
|
|
97
|
+
plain ASCII and is not flagged."""
|
|
98
|
+
return any("\udc80" <= ch <= "\udcff" for ch in text)
|
|
99
|
+
|
|
100
|
+
|
|
92
101
|
def _is_valid_urn(value: Any) -> bool:
|
|
93
102
|
"""A usable identity: a string that is not blank under the ASCII set (ADR-0032 §5/§6).
|
|
94
103
|
|
|
@@ -299,6 +308,42 @@ class EnvelopeCodec:
|
|
|
299
308
|
parsed = _parse_object(raw)
|
|
300
309
|
return {} if parsed is None else _fold_identity(parsed)
|
|
301
310
|
|
|
311
|
+
@staticmethod
|
|
312
|
+
def poison_cause(raw: str, *, require_identity: bool = True) -> Optional[str]:
|
|
313
|
+
"""Why ``raw`` is a **poison** body, or ``None`` when it decodes into an envelope with an
|
|
314
|
+
identity (ADR-0036 decision 4, conformance ``identity_verdict``).
|
|
315
|
+
|
|
316
|
+
A body is poison when it is not exactly one JSON object (malformed, BOM, trailing tokens,
|
|
317
|
+
``NaN`` / ``Infinity``, a bare ``null``, an array, a scalar), when a known member has the
|
|
318
|
+
wrong type (the trigger set of :func:`~babelqueue._envelope_shape.envelope_violation`, the
|
|
319
|
+
Go reference's typed ``Decode``), when the core parser cannot read it, or, with
|
|
320
|
+
``require_identity``, when it has neither a valid string ``job`` nor a valid string
|
|
321
|
+
``urn`` (ADR-0032 section 6: a ``job`` that is not a string is ignored, a blank one falls
|
|
322
|
+
back to ``urn``, a ``urn`` that is not a non-blank string is ignored; the last value of a
|
|
323
|
+
repeated member wins). A poison body is never handed to a handler and never takes the
|
|
324
|
+
unknown-URN strategies, which are for a valid envelope whose URN has no handler.
|
|
325
|
+
|
|
326
|
+
``require_identity=False`` keeps only the structural checks, for a delivery that a
|
|
327
|
+
transport routes on an out-of-band identity property (the Pulsar ``bq-job``), which is
|
|
328
|
+
outside the identity rule. Pure: no warning is logged and nothing is mutated."""
|
|
329
|
+
if has_invalid_utf8(raw):
|
|
330
|
+
# error-handling.md section 7: a body that is not valid UTF-8 is poison even when its
|
|
331
|
+
# invalid bytes sit inside a JSON string, where ``json.loads`` would accept the lone
|
|
332
|
+
# surrogate they were escaped to. It must never reach a handler or be re-encoded.
|
|
333
|
+
return "the body is not valid UTF-8"
|
|
334
|
+
try:
|
|
335
|
+
violation = envelope_violation(raw)
|
|
336
|
+
if violation is not None:
|
|
337
|
+
return violation
|
|
338
|
+
parsed = json.loads(raw)
|
|
339
|
+
except (ValueError, RecursionError, TypeError) as exc:
|
|
340
|
+
return "the core decoder cannot parse the body: " + str(exc)
|
|
341
|
+
if not isinstance(parsed, dict):
|
|
342
|
+
return "the JSON root is not an object"
|
|
343
|
+
if require_identity and not (_is_valid_urn(parsed.get("job")) or _is_valid_urn(parsed.get("urn"))):
|
|
344
|
+
return "the body carries no valid 'job' or 'urn' identity (ADR-0032 section 6)"
|
|
345
|
+
return None
|
|
346
|
+
|
|
302
347
|
@staticmethod
|
|
303
348
|
def urn(envelope: Mapping[str, Any]) -> str:
|
|
304
349
|
"""The message URN: canonical ``job``, with ``urn`` accepted as an alias.
|
|
@@ -28,8 +28,10 @@ spliced byte for byte, anything else is carried as ``{"raw": "<body>"}`` (base64
|
|
|
28
28
|
``raw_encoding: "base64"`` when it is not valid UTF-8), and a ``dead_letter`` block with
|
|
29
29
|
``reason: "poison"`` is added (the key is preserved; the DLQ is the configured one or ``<queue>.dlq``, which must exist). With
|
|
30
30
|
the DLQ off it is NOT written to a DLQ (Go ``routePoison``): ``on_unknown_urn=DELETE`` drops it, any other
|
|
31
|
-
strategy takes the bounded FAIL path (retried through the tiers up to ``max_attempts``, then dropped)
|
|
32
|
-
|
|
31
|
+
strategy takes the bounded FAIL path (retried through the tiers up to ``max_attempts``, then dropped); with
|
|
32
|
+
no retry tiers configured as well, that path has no tier to retry through, so the record is committed, dropped
|
|
33
|
+
and logged (the §6.5 degrade-to-delete rule, ADR-0036 decision 5) instead of blocking the partition. The record is
|
|
34
|
+
handed over raw under a
|
|
33
35
|
``PoisonRoute`` and wrapped by ``publish_settle`` only when it is written to a queue other than the one
|
|
34
36
|
it was read from, so a ``redrive`` restore into the DLQ it popped it from (any DLQ name) is byte for
|
|
35
37
|
byte and never double-wraps. The version gate (§6.7) still
|
|
@@ -161,6 +163,10 @@ def _brokers_from_url(url: str) -> str:
|
|
|
161
163
|
|
|
162
164
|
|
|
163
165
|
class KafkaTransport(Transport):
|
|
166
|
+
#: Classifies poison itself (``_poll_one``, ADR-0034 section 10): the runtime does not classify
|
|
167
|
+
#: the body a second time.
|
|
168
|
+
classifies_poison = True
|
|
169
|
+
|
|
164
170
|
def __init__(
|
|
165
171
|
self,
|
|
166
172
|
url: str = "kafka://localhost:9092",
|
|
@@ -115,7 +115,9 @@ class PikaTransport(Transport):
|
|
|
115
115
|
if timeout and timeout > 0:
|
|
116
116
|
self._connection.sleep(timeout)
|
|
117
117
|
return None
|
|
118
|
-
|
|
118
|
+
# Lossless: bytes that are not UTF-8 stay recoverable (``surrogateescape``) so a poison
|
|
119
|
+
# body is dead-lettered as the ADR-0035 base64 record instead of crashing the consumer.
|
|
120
|
+
text = body.decode("utf-8", "surrogateescape") if isinstance(body, (bytes, bytearray)) else str(body)
|
|
119
121
|
headers = _delivery_headers(props)
|
|
120
122
|
return ReceivedMessage(body=text, queue=queue, handle=method.delivery_tag, headers=headers)
|
|
121
123
|
|
|
@@ -230,6 +230,17 @@ class PulsarTransport(Transport):
|
|
|
230
230
|
value = int(raw)
|
|
231
231
|
return value if value <= _MAX_ATTEMPTS else None
|
|
232
232
|
|
|
233
|
+
@staticmethod
|
|
234
|
+
def _native_attempts(redelivery_count: Any, properties: Optional[Dict[str, str]] = None) -> int:
|
|
235
|
+
"""``max(bq-attempts, RedeliveryCount)`` (§5.5; 0-based): the reconciled count of a delivery
|
|
236
|
+
that has no decodable body attempts. An invalid or absent ``bq-attempts`` counts as 0."""
|
|
237
|
+
from_property = PulsarTransport._parse_attempts((properties or {}).get("bq-attempts")) or 0
|
|
238
|
+
try:
|
|
239
|
+
native = int(redelivery_count)
|
|
240
|
+
except (TypeError, ValueError):
|
|
241
|
+
native = 0
|
|
242
|
+
return max(from_property, native, 0)
|
|
243
|
+
|
|
233
244
|
@staticmethod
|
|
234
245
|
def _reconcile(
|
|
235
246
|
body: str,
|
|
@@ -320,7 +331,9 @@ class PulsarTransport(Transport):
|
|
|
320
331
|
getter = getattr(message, "data", None)
|
|
321
332
|
data = getter() if callable(getter) else getter
|
|
322
333
|
if isinstance(data, bytes):
|
|
323
|
-
|
|
334
|
+
# Lossless: bytes that are not UTF-8 stay recoverable (``surrogateescape``) so a poison
|
|
335
|
+
# body is dead-lettered as the ADR-0035 base64 record instead of crashing the consumer.
|
|
336
|
+
return data.decode("utf-8", "surrogateescape")
|
|
324
337
|
return str(data) if data is not None else ""
|
|
325
338
|
|
|
326
339
|
# -- Transport ----------------------------------------------------------
|
|
@@ -370,13 +383,33 @@ class PulsarTransport(Transport):
|
|
|
370
383
|
properties,
|
|
371
384
|
self._on_routing_mismatch,
|
|
372
385
|
)
|
|
373
|
-
return ReceivedMessage(
|
|
386
|
+
return ReceivedMessage(
|
|
387
|
+
body=body,
|
|
388
|
+
queue=queue,
|
|
389
|
+
handle=message,
|
|
390
|
+
route_urn=route_urn,
|
|
391
|
+
# The §5.5 reconciled count without a body: what a poison body (no envelope to carry
|
|
392
|
+
# ``attempts``) is counted by (ADR-0036 decision 2).
|
|
393
|
+
native_attempts=self._native_attempts(self._redelivery_count(message), properties),
|
|
394
|
+
)
|
|
374
395
|
|
|
375
396
|
def ack(self, message: ReceivedMessage) -> None:
|
|
376
397
|
if message.handle is None:
|
|
377
398
|
return
|
|
378
399
|
self._consumer(message.queue).acknowledge(message.handle)
|
|
379
400
|
|
|
401
|
+
def rewind(self, message: ReceivedMessage) -> None:
|
|
402
|
+
""":class:`~babelqueue.transport.Rewinder`: negatively acknowledge ``message`` so the broker
|
|
403
|
+
redelivers it (Go ``Rewind`` = ``Nack``; ADR-0036 decision 1: a failed DLQ write or retry
|
|
404
|
+
publish leaves the original un-acknowledged for redelivery instead of leaving it unsettled
|
|
405
|
+
for the life of the consumer). On a ``Shared`` subscription the native redelivery count
|
|
406
|
+
advances, which the §5.5 reconciliation reads back; on ``Exclusive`` / ``Failover`` it does
|
|
407
|
+
not, which is harmless: the attempt limit is carried by the ``bq-attempts`` property and
|
|
408
|
+
the body, never by the nack."""
|
|
409
|
+
if message.handle is None:
|
|
410
|
+
return
|
|
411
|
+
self._consumer(message.queue).negative_acknowledge(message.handle)
|
|
412
|
+
|
|
380
413
|
def close(self) -> None: # pragma: no cover - resource cleanup
|
|
381
414
|
for resource in (*self._producers.values(), *self._consumers.values()):
|
|
382
415
|
try:
|