babelqueue 1.18.0__tar.gz → 1.20.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.18.0 → babelqueue-1.20.0}/.github/workflows/ci.yml +1 -1
- {babelqueue-1.18.0 → babelqueue-1.20.0}/CHANGELOG.md +81 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/PKG-INFO +24 -1
- {babelqueue-1.18.0 → babelqueue-1.20.0}/README.md +23 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/pyproject.toml +1 -1
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/__init__.py +1 -1
- babelqueue-1.20.0/src/babelqueue/_envelope_shape.py +156 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/app.py +18 -5
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/codec.py +107 -32
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/exceptions.py +15 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/kafka_transport.py +176 -53
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/pulsar_transport.py +10 -5
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/redrive.py +6 -2
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/sqs_transport.py +5 -2
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/transport.py +27 -0
- babelqueue-1.20.0/tests/conformance/CONFORMANCE_VERSION +1 -0
- babelqueue-1.20.0/tests/conformance/fixtures/blank-ascii-job-urn-fallback.json +16 -0
- babelqueue-1.20.0/tests/conformance/fixtures/dead-letter-roundtrip.json +23 -0
- babelqueue-1.20.0/tests/conformance/fixtures/duplicate-keys-last-wins.json +19 -0
- babelqueue-1.20.0/tests/conformance/fixtures/forbidden-key-job-and-urn.json +16 -0
- babelqueue-1.20.0/tests/conformance/fixtures/forbidden-key-job-nonascii-space-and-urn.json +16 -0
- babelqueue-1.20.0/tests/conformance/fixtures/invalid-wrong-case-job.json +15 -0
- babelqueue-1.20.0/tests/conformance/fixtures/meta-ext-vendor-roundtrip.json +27 -0
- babelqueue-1.20.0/tests/conformance/fixtures/urn-only-roundtrip.json +15 -0
- babelqueue-1.20.0/tests/conformance/fixtures/wrong-case-keys-roundtrip.json +20 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/manifest.json +323 -2
- babelqueue-1.20.0/tests/poison-trigger-set.json +392 -0
- babelqueue-1.20.0/tests/test_behaviour_conformance.py +513 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_codec.py +5 -7
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_kafka_transport.py +428 -43
- babelqueue-1.20.0/tests/test_reencode_regression.py +186 -0
- babelqueue-1.18.0/tests/conformance/CONFORMANCE_VERSION +0 -1
- babelqueue-1.18.0/tests/test_behaviour_conformance.py +0 -195
- {babelqueue-1.18.0 → babelqueue-1.20.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/.github/dependabot.yml +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/.github/workflows/release.yml +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/.gitignore +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/LICENSE +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/artemis_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/asb_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/gdpr.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/headers.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/idempotency.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/otel.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/outbox.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/redis_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/replay.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/schema.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/fakes/confluent_kafka/__init__.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/fakes/confluent_kafka/admin.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_app.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_artemis_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_asb_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_celery.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_conformance.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_django.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_gdpr.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_headers.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_idempotency.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_kafka_integration.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_kafka_integration_logic.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_otel.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_outbox.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_pulsar_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_redis_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_redrive.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_replay.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_schema.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_schema_conformance.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_shutdown.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_sqs_conformance.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_sqs_transport.py +0 -0
|
@@ -166,7 +166,7 @@ jobs:
|
|
|
166
166
|
- uses: actions/checkout@v7
|
|
167
167
|
- name: Verify vendored conformance matches the canonical suite
|
|
168
168
|
run: |
|
|
169
|
-
git clone --depth 1 --branch v1.
|
|
169
|
+
git clone --depth 1 --branch v1.4.0 https://github.com/BabelQueue/conformance.git "$RUNNER_TEMP/conformance"
|
|
170
170
|
diff -u "$RUNNER_TEMP/conformance/CONFORMANCE_VERSION" "tests/conformance/CONFORMANCE_VERSION"
|
|
171
171
|
diff -ru "$RUNNER_TEMP/conformance/manifest.json" "tests/conformance/manifest.json"
|
|
172
172
|
diff -ru "$RUNNER_TEMP/conformance/fixtures" "tests/conformance/fixtures"
|
|
@@ -9,6 +9,87 @@ The envelope wire format is versioned separately by `meta.schema_version`
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [1.20.0] - 2026-10-17
|
|
13
|
+
|
|
14
|
+
Conformance suite 1.4.0, [ADR-0031](https://babelqueue.com). The envelope is unchanged
|
|
15
|
+
(`schema_version: 1`); this is a MINOR with one deliberate behaviour change (encode rejects).
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- `EnvelopeCodec.make(urn, data, *, queue, trace_id, meta=None)`: a producer `meta` write API. The entries
|
|
19
|
+
land in `meta` next to the core keys (recommended shape `meta.ext.<vendor>`). A collision with a core
|
|
20
|
+
meta key or a forbidden key raises the new `EnvelopeKeyError` (subclass of `BabelQueueError`, with a
|
|
21
|
+
`pointer` attribute). `_envelope_shape.CORE_META_KEYS` is the single copy of the core key list.
|
|
22
|
+
- `decode` folds the `urn` alias into `job` (ADR-0031 (c)): a `job` that is missing, empty or only ASCII
|
|
23
|
+
whitespace (`" \t\n\v\f\r"`; a U+2003 `job` is NOT blank) falls back to `urn` silently; when both carry
|
|
24
|
+
a value, `job` wins and `urn` is dropped with a warning naming `/urn`. `accepts()` / `urn()` follow the
|
|
25
|
+
same rule. The decoded envelope never keeps a `urn` key.
|
|
26
|
+
- Tests: the runner now implements the 1.4.0 fields (`expect_preserved` on `forbidden_keys`;
|
|
27
|
+
`expect_absent_after_reencode`, `apply_dead_letter`, `expect_dead_letter` on `roundtrip`, the latter via
|
|
28
|
+
`dead_letter.annotate`) plus the `extension_write` and `encode_reject` sections, with a runner
|
|
29
|
+
self-test proving violations are reported.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- **Behaviour change: `EnvelopeCodec.encode` raises `EnvelopeKeyError`** for an envelope carrying a
|
|
33
|
+
forbidden key (message-envelope.md section 10) or a top-level `urn`, instead of dropping it with a
|
|
34
|
+
warning (policy K-15 (a)). Nothing is written. `decode` still warns and drops, so re-encoding a decoded
|
|
35
|
+
envelope is safe. Code that built an envelope from a raw legacy body and relied on encode to clean it
|
|
36
|
+
must decode it first (`EnvelopeCodec.encode(EnvelopeCodec.decode(raw))`).
|
|
37
|
+
- `test_all_five_contract_keys_are_covered` is a subset check (the suite adds `/urn` cases).
|
|
38
|
+
- Docs only: the note in the previous release that the Go reference's case-insensitive envelope-key read "is tightened separately" is now closed — Go core 1.14.0 reads envelope keys exactly (case-sensitive, last duplicate wins), so every SDK now decides poison on the same exact-case keys.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
- Raw-body re-encode paths now decode before they encode, so the new `encode` rejection cannot strand a
|
|
42
|
+
legacy body: the runtime's raw-body RELEASE re-publish (`app.py`), and the SQS and Pulsar attempts
|
|
43
|
+
reconciliation (Kafka, Azure Service Bus and Artemis already decoded first). A body with a legacy
|
|
44
|
+
`timestamp` or only a `urn` alias is re-emitted normalised (forbidden key dropped and warned, `urn`
|
|
45
|
+
folded into `job`); unknown top-level and `meta` keys, including `meta.ext`, are preserved.
|
|
46
|
+
|
|
47
|
+
## [1.19.0] - 2026-10-12
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
- Tests only: the vendored conformance suite is now 1.3.0 (exact-case envelope keys, last-wins duplicates: new `invalid-wrong-case-job`, `wrong-case-keys-roundtrip` and `duplicate-keys-last-wins` fixtures). No production change; the Python core already behaved this way.
|
|
51
|
+
- **Kafka: a poison record is dead-lettered wrapped (`dead_letter.reason: "poison"`), like the Go
|
|
52
|
+
reference (§6.5, ADR-0009).** A value that is not valid UTF-8, not valid JSON, not a JSON object, or has
|
|
53
|
+
a member of the wrong type (the exact set is listed in the next entry) used to reach `<topic>.dlq` as its raw
|
|
54
|
+
bytes. It is now written as a body that keeps its keys when it is exactly one JSON object, otherwise
|
|
55
|
+
`{"raw": "<body>"}` (bytes that are not UTF-8 are stored with U+FFFD substitutions), plus a
|
|
56
|
+
`dead_letter` block (`reason: "poison"`, `original_queue`, `attempts` from the `bq-attempts` header, `lang`);
|
|
57
|
+
the record key is preserved. A poison record is still never retried and is dropped when no DLQ is
|
|
58
|
+
configured (the `on_unknown_urn` strategy, `DELETE` included, no longer matters: see below). The version-gate rejection (`bq-schema-version`, §6.7) still
|
|
59
|
+
dead-letters its record raw. The wrap is applied when the runtime writes the DLQ record (not when the
|
|
60
|
+
record is read), so a poison record that `redrive` restores into the DLQ it popped it from stays byte
|
|
61
|
+
for byte, whatever that DLQ is named. `original_queue` is the `bq-original-topic` header when present
|
|
62
|
+
(else the consumed topic), like the Java, .NET and Node consumers; a body whose numbers cannot be
|
|
63
|
+
written back as valid JSON (e.g. `1e400`) is carried as `{"raw": ...}` instead of producing an invalid DLQ body.
|
|
64
|
+
A body that decodes but names no URN is not poison (unknown-URN path, as before). Consumers of the DLQ
|
|
65
|
+
that expected the raw bytes of a malformed record must now read the wrapped body.
|
|
66
|
+
- **Kafka: the poison trigger set now equals what the Go reference's typed `Decode` rejects.** Behaviour
|
|
67
|
+
change; the old Python-only rules (a lenient `attempts` read, `Accepts`-style conformance checks) are gone.
|
|
68
|
+
A record is poison when it is not valid UTF-8, not valid JSON (an empty or whitespace-only body, a BOM,
|
|
69
|
+
trailing data, leading zeros, `NaN` / `Infinity`, single quotes), or not a JSON object (a bare `null` is
|
|
70
|
+
not poison: it is an empty envelope and takes the unknown-URN path), or when a member has the wrong type:
|
|
71
|
+
`job`, `trace_id`, `meta.id|queue|lang` and `dead_letter.reason|error|exception|original_queue|lang`
|
|
72
|
+
must be a string or null; `meta.schema_version`, `meta.created_at`, `attempts` and
|
|
73
|
+
`dead_letter.failed_at|attempts` must be an int64 integer literal or null (a string such as `"3"` or
|
|
74
|
+
`"2026"`, a fraction or exponent such as `3.0` / `1e2`, a bool, an array and an object are poison);
|
|
75
|
+
`data`, `meta` and `dead_letter` must be an object or null; every number inside `data` must be a finite
|
|
76
|
+
double. Member names are matched exactly as spelled (case-sensitive, as the contract schema and the core decoder read them; a `JOB` or `Meta` member is an unknown member, not a typed one) and every duplicate key is checked. A record Go decodes
|
|
77
|
+
is no longer poison, even when it is not a conformant envelope: a non-string `urn` alias, an absent
|
|
78
|
+
`data`, `"attempts": null` (read as 0 and written back as 0) and `meta.schema_version: 2` are routed by URN
|
|
79
|
+
like any other record (Go's consume path never calls `Accepts`). The Kafka transport no longer
|
|
80
|
+
delivers records under the shared non-numeric-`attempts` route; they are plain poison now.
|
|
81
|
+
`redrive` restores a poison-routed record into the DLQ unchanged instead of replaying or re-wrapping it,
|
|
82
|
+
even when its `job` still reads as a URN. The only Python-specific limit is JSON nested deeper than
|
|
83
|
+
the interpreter's recursion limit (about 1000 levels, Go allows 10000), which is treated as poison.
|
|
84
|
+
- Known, intentional differences from the Go reference for a poison record: `dead_letter.attempts` is the `bq-attempts` header (Go writes 0) and `original_queue` is the `bq-original-topic` header when present (Go writes the consumed topic); a float literal such as `1.0` / `1e2` is re-formatted when the body is re-encoded. The wire envelope and the `dead_letter` shape are unchanged.
|
|
85
|
+
- **Kafka: any decode failure is poison; the partition is never left to wedge.** Member names are matched case-sensitively, exactly as the core decoder reads them (no `encoding/json`-style folding, no `str.casefold()`); the Go reference's case-insensitive read is a known difference that contradicts the contract schema and is tightened separately. Whatever `EnvelopeCodec.decode` raises after the shape scan (e.g. `RecursionError` at the interpreter's limit) is now routed as poison instead of escaping `pop` (which left the offset uncommitted and the partition redelivered forever); the poison wrapper falls back to `{"raw": ...}` on `RecursionError` or a body that cannot be written as UTF-8. A lone surrogate escape (`"\ud800"`) that survives into a retry/DLQ write is substituted by U+FFFD, as Go does, instead of raising `UnicodeEncodeError`. Invalid UTF-8 remains poison (Go substitutes U+FFFD and accepts it). The wire envelope and the `dead_letter` shape are unchanged.
|
|
86
|
+
- **Kafka: a body the core JSON parser refuses is poison, not an empty envelope.** The core decoder turns a `ValueError` from `json.loads` into an empty envelope; where the shape scan accepts a body that plain `json.loads` rejects (Python 3.11+: an unknown member holding an integer of more than 4300 digits), the record would have reached a handler with its content dropped. It is now dead-lettered as poison. No change to the wire envelope or the `dead_letter` shape.
|
|
87
|
+
- **Kafka: a poison record is dead-lettered whatever `on_unknown_urn` is.** The runtime checked the `DELETE` strategy before it recognised a poison record, so with `on_unknown_urn="delete"` a poison record was acknowledged and silently dropped even with a DLQ configured, where the Go reference writes it to `<topic>.dlq` with `dead_letter.reason: "poison"` and commits. A poison delivery (`PoisonRoute`) is now settled before the strategy is consulted: written wrapped to the DLQ and committed under `fail`, `delete`, `dead_letter` and `release`, and dropped only when no DLQ is configured (unchanged). The version-gate path and every other transport keep their behaviour. No change to the wire envelope or the `dead_letter` shape.
|
|
88
|
+
|
|
89
|
+
### Fixed
|
|
90
|
+
- Kafka: a wrongly-typed `meta` or `meta.created_at` in a wrapped poison body no longer fails the write
|
|
91
|
+
that settles it (header projection and record timestamp tolerate it).
|
|
92
|
+
|
|
12
93
|
## [1.18.0] - 2026-10-11
|
|
13
94
|
|
|
14
95
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: babelqueue
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.20.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
|
|
@@ -106,6 +106,29 @@ The envelope is identical to every other SDK's:
|
|
|
106
106
|
}
|
|
107
107
|
```
|
|
108
108
|
|
|
109
|
+
### Extension metadata, forbidden keys and the `urn` alias (1.20.0, ADR-0031)
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
# Producer meta: extra keys next to the core meta keys (recommended shape: meta.ext.<vendor>).
|
|
113
|
+
envelope = EnvelopeCodec.make(
|
|
114
|
+
"urn:babel:orders:created", {"order_id": 1042},
|
|
115
|
+
queue="orders", meta={"ext": {"acme": {"tier": "gold"}}},
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- `meta=` is keyword-only. A key that collides with a core meta key (`id`, `queue`, `lang`,
|
|
120
|
+
`schema_version`, `created_at`) or with a forbidden key (`max_retries`, `attempts`, `source`, `ts`)
|
|
121
|
+
raises `EnvelopeKeyError` (a `BabelQueueError`; its `pointer` names the key, e.g. `/meta/ts`).
|
|
122
|
+
- Unknown top-level and `meta` keys of an inbound message survive a decode -> `attempts + 1` -> encode
|
|
123
|
+
hop (retry, redrive, dead-letter), so a vendor `meta.ext` block is never lost.
|
|
124
|
+
- `EnvelopeCodec.encode` **rejects** an envelope that carries a forbidden key (top-level `timestamp`;
|
|
125
|
+
`meta.max_retries`, `meta.attempts`, `meta.source`, `meta.ts`) or the inbound-only top-level `urn`
|
|
126
|
+
alias: it raises `EnvelopeKeyError` instead of dropping the key with a warning. `decode` is unchanged
|
|
127
|
+
(it still warns and drops), so a decoded envelope can always be re-encoded.
|
|
128
|
+
- `decode` folds `urn` into `job`: a `job` that is missing, empty or only ASCII whitespace (space, `\t`,
|
|
129
|
+
`\n`, `\v`, `\f`, `\r`) falls back to `urn`; when both carry a value, `job` wins and `urn` is dropped
|
|
130
|
+
with a warning naming `/urn`. The decoded envelope never keeps a `urn` key.
|
|
131
|
+
|
|
109
132
|
### Typed messages (optional)
|
|
110
133
|
|
|
111
134
|
```python
|
|
@@ -52,6 +52,29 @@ The envelope is identical to every other SDK's:
|
|
|
52
52
|
}
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
### Extension metadata, forbidden keys and the `urn` alias (1.20.0, ADR-0031)
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
# Producer meta: extra keys next to the core meta keys (recommended shape: meta.ext.<vendor>).
|
|
59
|
+
envelope = EnvelopeCodec.make(
|
|
60
|
+
"urn:babel:orders:created", {"order_id": 1042},
|
|
61
|
+
queue="orders", meta={"ext": {"acme": {"tier": "gold"}}},
|
|
62
|
+
)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- `meta=` is keyword-only. A key that collides with a core meta key (`id`, `queue`, `lang`,
|
|
66
|
+
`schema_version`, `created_at`) or with a forbidden key (`max_retries`, `attempts`, `source`, `ts`)
|
|
67
|
+
raises `EnvelopeKeyError` (a `BabelQueueError`; its `pointer` names the key, e.g. `/meta/ts`).
|
|
68
|
+
- Unknown top-level and `meta` keys of an inbound message survive a decode -> `attempts + 1` -> encode
|
|
69
|
+
hop (retry, redrive, dead-letter), so a vendor `meta.ext` block is never lost.
|
|
70
|
+
- `EnvelopeCodec.encode` **rejects** an envelope that carries a forbidden key (top-level `timestamp`;
|
|
71
|
+
`meta.max_retries`, `meta.attempts`, `meta.source`, `meta.ts`) or the inbound-only top-level `urn`
|
|
72
|
+
alias: it raises `EnvelopeKeyError` instead of dropping the key with a warning. `decode` is unchanged
|
|
73
|
+
(it still warns and drops), so a decoded envelope can always be re-encoded.
|
|
74
|
+
- `decode` folds `urn` into `job`: a `job` that is missing, empty or only ASCII whitespace (space, `\t`,
|
|
75
|
+
`\n`, `\v`, `\f`, `\r`) falls back to `urn`; when both carry a value, `job` wins and `urn` is dropped
|
|
76
|
+
with a warning naming `/urn`. The decoded envelope never keeps a `urn` key.
|
|
77
|
+
|
|
55
78
|
### Typed messages (optional)
|
|
56
79
|
|
|
57
80
|
```python
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "babelqueue"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.20.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"
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""The poison trigger set of the Kafka transport (§6.5).
|
|
2
|
+
|
|
3
|
+
A body is undecodable when a strict, typed decode into ``Envelope`` / ``Meta`` / ``DeadLetter`` (the
|
|
4
|
+
Go reference's ``babelqueue.Decode``, except that member names are case-sensitive here) rejects it:
|
|
5
|
+
|
|
6
|
+
* the body must be exactly one JSON value (no BOM, trailing tokens, ``NaN`` / ``Infinity``, malformed
|
|
7
|
+
numbers); a bare ``null`` decodes to an empty envelope, any other non-object root is rejected;
|
|
8
|
+
* ``job``, ``trace_id``, ``meta.id|queue|lang`` and ``dead_letter.reason|error|exception|original_queue|lang``
|
|
9
|
+
are string-or-null;
|
|
10
|
+
* ``attempts``, ``meta.schema_version|created_at`` and ``dead_letter.failed_at|attempts`` are
|
|
11
|
+
integer-or-null: an integer literal (no fraction or exponent) that fits ``int64``;
|
|
12
|
+
* ``data``, ``meta`` and ``dead_letter`` are object-or-null, and a number inside ``data`` must be a
|
|
13
|
+
finite double;
|
|
14
|
+
* member names are matched exactly as spelled (case-sensitive, as the contract schema and the core
|
|
15
|
+
decoder read them) and every occurrence of a duplicated key is checked; any other member (a
|
|
16
|
+
differently-cased name or a non-string ``urn`` alias included) is only checked for syntax.
|
|
17
|
+
|
|
18
|
+
A well-typed but non-conformant envelope is not poison: Go's consume path never calls ``Accepts``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import math
|
|
23
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
_INT64_MIN = -(2**63)
|
|
26
|
+
_INT64_MAX = 2**63 - 1
|
|
27
|
+
#: Beyond this many digits an integer literal cannot fit an int64 or a double; it is kept as a
|
|
28
|
+
#: sentinel so ``json`` never trips the interpreter's integer-string-length limit.
|
|
29
|
+
_MAX_INT_DIGITS = 4000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Pairs(List[Tuple[str, Any]]):
|
|
33
|
+
"""A decoded JSON object as its ``(key, value)`` list, so duplicate keys survive."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _HugeInt:
|
|
37
|
+
"""An integer literal too large for any int64 or double."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_HUGE = _HugeInt()
|
|
41
|
+
|
|
42
|
+
_STRING = "string"
|
|
43
|
+
_INTEGER = "integer"
|
|
44
|
+
_OBJECT = "object"
|
|
45
|
+
_DATA = "data"
|
|
46
|
+
|
|
47
|
+
_META = {
|
|
48
|
+
"id": _STRING,
|
|
49
|
+
"queue": _STRING,
|
|
50
|
+
"lang": _STRING,
|
|
51
|
+
"schema_version": _INTEGER,
|
|
52
|
+
"created_at": _INTEGER,
|
|
53
|
+
}
|
|
54
|
+
#: The core ``meta`` keys of message-envelope.md section 4 (the single copy; the producer ``meta``
|
|
55
|
+
#: write API of the codec rejects a collision with any of them, ADR-0031 (b)).
|
|
56
|
+
CORE_META_KEYS = frozenset(_META)
|
|
57
|
+
_DEAD_LETTER = {
|
|
58
|
+
"reason": _STRING,
|
|
59
|
+
"error": _STRING,
|
|
60
|
+
"exception": _STRING,
|
|
61
|
+
"original_queue": _STRING,
|
|
62
|
+
"lang": _STRING,
|
|
63
|
+
"failed_at": _INTEGER,
|
|
64
|
+
"attempts": _INTEGER,
|
|
65
|
+
}
|
|
66
|
+
_TOP = {
|
|
67
|
+
"job": _STRING,
|
|
68
|
+
"trace_id": _STRING,
|
|
69
|
+
"attempts": _INTEGER,
|
|
70
|
+
"data": _DATA,
|
|
71
|
+
"meta": _OBJECT,
|
|
72
|
+
"dead_letter": _OBJECT,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _reject_constant(name: str) -> Any:
|
|
77
|
+
raise ValueError("invalid JSON constant " + name)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_int(literal: str) -> Any:
|
|
81
|
+
return int(literal) if len(literal) <= _MAX_INT_DIGITS else _HUGE
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_finite_number(value: Any) -> bool:
|
|
85
|
+
if value is _HUGE:
|
|
86
|
+
return False
|
|
87
|
+
if isinstance(value, float):
|
|
88
|
+
return math.isfinite(value)
|
|
89
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
90
|
+
try:
|
|
91
|
+
return math.isfinite(float(value))
|
|
92
|
+
except OverflowError:
|
|
93
|
+
return False
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _all_numbers_finite(root: Any) -> bool:
|
|
98
|
+
stack = [root]
|
|
99
|
+
while stack:
|
|
100
|
+
value = stack.pop()
|
|
101
|
+
if isinstance(value, _Pairs):
|
|
102
|
+
stack.extend(item for _, item in value)
|
|
103
|
+
elif isinstance(value, list):
|
|
104
|
+
stack.extend(value)
|
|
105
|
+
elif not _is_finite_number(value):
|
|
106
|
+
return False
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _check_member(kind: str, name: str, value: Any) -> Optional[str]:
|
|
111
|
+
if value is None:
|
|
112
|
+
return None
|
|
113
|
+
if kind == _STRING:
|
|
114
|
+
return None if isinstance(value, str) else "member %r is not a string" % name
|
|
115
|
+
if kind == _INTEGER:
|
|
116
|
+
if type(value) is int and _INT64_MIN <= value <= _INT64_MAX:
|
|
117
|
+
return None
|
|
118
|
+
return "member %r is not an int64 integer" % name
|
|
119
|
+
if not isinstance(value, _Pairs):
|
|
120
|
+
return "member %r is not an object" % name
|
|
121
|
+
if kind == _DATA and not _all_numbers_finite(value):
|
|
122
|
+
return "member %r holds a number that is not a finite double" % name
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _check_object(pairs: _Pairs, kinds: Dict[str, str], prefix: str) -> Optional[str]:
|
|
127
|
+
for key, value in pairs:
|
|
128
|
+
kind = kinds.get(key)
|
|
129
|
+
if kind is None:
|
|
130
|
+
continue
|
|
131
|
+
failure = _check_member(kind, prefix + key, value)
|
|
132
|
+
if failure is not None:
|
|
133
|
+
return failure
|
|
134
|
+
if kind == _OBJECT and value is not None:
|
|
135
|
+
nested = _check_object(value, _META if key == "meta" else _DEAD_LETTER, key + ".")
|
|
136
|
+
if nested is not None:
|
|
137
|
+
return nested
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def envelope_violation(text: str) -> Optional[str]:
|
|
142
|
+
"""Why ``text`` is not a decodable BabelQueue body, or ``None`` when Go's typed decode accepts it."""
|
|
143
|
+
try:
|
|
144
|
+
parsed = json.loads(
|
|
145
|
+
text,
|
|
146
|
+
object_pairs_hook=_Pairs,
|
|
147
|
+
parse_int=_parse_int,
|
|
148
|
+
parse_constant=_reject_constant,
|
|
149
|
+
)
|
|
150
|
+
except (ValueError, RecursionError) as exc:
|
|
151
|
+
return "invalid JSON: " + str(exc)
|
|
152
|
+
if parsed is None:
|
|
153
|
+
return None # a bare ``null`` is an empty envelope, not poison
|
|
154
|
+
if not isinstance(parsed, _Pairs):
|
|
155
|
+
return "the JSON root is not an object"
|
|
156
|
+
return _check_object(parsed, _TOP, "")
|
|
@@ -41,6 +41,7 @@ from .replay import HEADER_REPLAY_BYPASS, _replay_scope
|
|
|
41
41
|
from .routing import UnknownUrnStrategy
|
|
42
42
|
from .transport import (
|
|
43
43
|
HeaderPublisher,
|
|
44
|
+
PoisonRoute,
|
|
44
45
|
ReceivedMessage,
|
|
45
46
|
Redeliverer,
|
|
46
47
|
Rewinder,
|
|
@@ -457,8 +458,9 @@ class BabelQueue:
|
|
|
457
458
|
self._stop.wait(self._rewind_pause)
|
|
458
459
|
|
|
459
460
|
def _release_unprocessed(self, received: ReceivedMessage) -> None:
|
|
460
|
-
"""Release a message whose handler never completed (shutdown): unchanged body
|
|
461
|
-
|
|
461
|
+
"""Release a message whose handler never completed (shutdown): unchanged body
|
|
462
|
+
(normalised only if it carries a forbidden key or a ``urn`` alias), no backoff, and the
|
|
463
|
+
runtime adds no ``attempts`` increment. A broker that counts
|
|
462
464
|
deliveries still counts it: on SQS the release bumps ``ApproximateReceiveCount``, so the
|
|
463
465
|
redelivered envelope reads one more attempt (and a ``RedrivePolicy`` sees it too)."""
|
|
464
466
|
self._release(received, received.body, 0.0)
|
|
@@ -476,6 +478,14 @@ class BabelQueue:
|
|
|
476
478
|
|
|
477
479
|
def _route_unknown(self, urn: str, received: ReceivedMessage, envelope: Mapping[str, Any]) -> None:
|
|
478
480
|
strategy = self.on_unknown_urn
|
|
481
|
+
# A Kafka poison record (§6.5: not valid UTF-8 / not a decodable body) is not an unknown URN: it
|
|
482
|
+
# never decoded into an envelope, so ``on_unknown_urn`` has no say. It is checked before the
|
|
483
|
+
# DELETE strategy and settled like the Go reference does -- dead-lettered (wrapped, reason
|
|
484
|
+
# ``poison``) and committed, or dropped only when no DLQ is configured. Other transports'
|
|
485
|
+
# version-gated deliveries keep their existing order below.
|
|
486
|
+
if isinstance(received.route_urn, PoisonRoute):
|
|
487
|
+
self._settle_version_gated(received)
|
|
488
|
+
return
|
|
479
489
|
if strategy == UnknownUrnStrategy.DELETE:
|
|
480
490
|
self._ack_settled(received)
|
|
481
491
|
return
|
|
@@ -569,12 +579,15 @@ _UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
|
|
|
569
579
|
|
|
570
580
|
def _without_forbidden_keys(body: str) -> str:
|
|
571
581
|
"""``body`` unchanged, unless it carries a forbidden envelope key (message-envelope.md §10,
|
|
572
|
-
e.g. from a legacy producer) — then it is re-encoded without it (warned by the codec), so a
|
|
582
|
+
e.g. from a legacy producer) or a ``urn`` alias — then it is re-encoded without it (warned by the codec), so a
|
|
573
583
|
raw-body re-publish never re-emits a key the decode boundary reported as dropped."""
|
|
574
584
|
parsed = parse_envelope(body)
|
|
575
|
-
|
|
585
|
+
# ``urn`` is not a §10 key but ``encode`` rejects it and ADR-0031 (a)/(c) forbid re-emitting it.
|
|
586
|
+
if not parsed or not (has_forbidden_keys(parsed) or "urn" in parsed):
|
|
576
587
|
return body
|
|
577
|
-
|
|
588
|
+
# ``decode`` (not the raw parse) so the dropped keys — and a top-level ``urn`` alias, folded
|
|
589
|
+
# into ``job`` — are gone before ``encode``, which rejects them (ADR-0031 (c)).
|
|
590
|
+
return EnvelopeCodec.encode(EnvelopeCodec.decode(body))
|
|
578
591
|
|
|
579
592
|
|
|
580
593
|
def _handler_wants_envelope(fn: Handler) -> bool:
|
|
@@ -16,15 +16,17 @@ import time
|
|
|
16
16
|
import uuid
|
|
17
17
|
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
|
18
18
|
|
|
19
|
-
from .
|
|
19
|
+
from ._envelope_shape import CORE_META_KEYS
|
|
20
|
+
from .exceptions import BabelQueueError, EnvelopeKeyError
|
|
20
21
|
|
|
21
22
|
SCHEMA_VERSION = 1
|
|
22
23
|
SOURCE_LANG = "python"
|
|
23
24
|
|
|
24
25
|
#: Non-canonical keys (message-envelope.md §10), as ``(container, key)`` pairs where the
|
|
25
26
|
#: container is ``None`` for the top level or ``"meta"``. Policy K-15: decode WARNS and drops
|
|
26
|
-
#: them (old producers keep working); encode NEVER emits them
|
|
27
|
-
#:
|
|
27
|
+
#: them (old producers keep working); encode NEVER emits them and REJECTS an envelope that
|
|
28
|
+
#: carries one (ADR-0031 (c)). The top-level ``urn`` alias is not in this table: it is an inbound
|
|
29
|
+
#: alias that decode folds into ``job`` (see :meth:`EnvelopeCodec.decode`), but encode rejects it.
|
|
28
30
|
FORBIDDEN_KEYS: Tuple[Tuple[Optional[str], str], ...] = (
|
|
29
31
|
(None, "timestamp"),
|
|
30
32
|
("meta", "max_retries"),
|
|
@@ -33,6 +35,10 @@ FORBIDDEN_KEYS: Tuple[Tuple[Optional[str], str], ...] = (
|
|
|
33
35
|
("meta", "ts"),
|
|
34
36
|
)
|
|
35
37
|
|
|
38
|
+
#: The closed ASCII whitespace set that makes a ``job`` "blank" (cross-SDK parity, GR-5): never a
|
|
39
|
+
#: bare ``str.strip()``, which also strips Unicode spaces such as U+2003.
|
|
40
|
+
_ASCII_WHITESPACE = " \t\n\v\f\r"
|
|
41
|
+
|
|
36
42
|
#: Logger every forbidden-key warning is reported through (one record per dropped key).
|
|
37
43
|
logger = logging.getLogger("babelqueue.codec")
|
|
38
44
|
|
|
@@ -52,11 +58,7 @@ def strip_forbidden_keys(envelope: Dict[str, Any]) -> List[str]:
|
|
|
52
58
|
del target[key]
|
|
53
59
|
pointer = "/" + key if container is None else f"/{container}/{key}"
|
|
54
60
|
dropped.append(pointer)
|
|
55
|
-
|
|
56
|
-
"Dropped forbidden envelope key %s from the envelope "
|
|
57
|
-
"(non-canonical, message-envelope.md §10).",
|
|
58
|
-
pointer,
|
|
59
|
-
)
|
|
61
|
+
_warn_dropped(pointer)
|
|
60
62
|
return dropped
|
|
61
63
|
|
|
62
64
|
|
|
@@ -76,10 +78,42 @@ def parse_envelope(raw: Any) -> Dict[str, Any]:
|
|
|
76
78
|
|
|
77
79
|
|
|
78
80
|
def has_forbidden_keys(envelope: Mapping[str, Any]) -> bool:
|
|
79
|
-
"""Whether ``envelope`` carries any of the non-canonical keys of message-envelope.md §10.
|
|
81
|
+
"""Whether ``envelope`` carries any of the non-canonical keys of message-envelope.md §10.
|
|
82
|
+
|
|
83
|
+
The top-level ``urn`` alias is NOT included (it is not a §10 key: decode folds it into ``job``),
|
|
84
|
+
although ``encode`` also rejects it — a caller deciding whether a raw body needs normalising
|
|
85
|
+
before re-emit must check ``"urn" in envelope`` as well.
|
|
86
|
+
"""
|
|
80
87
|
return _has_forbidden_keys(envelope)
|
|
81
88
|
|
|
82
89
|
|
|
90
|
+
def _is_blank(value: Any) -> bool:
|
|
91
|
+
"""Missing, ``None``, empty or only ASCII whitespace; a falsy non-string (``0``, ``false``,
|
|
92
|
+
``[]``, ``{}``) is blank too, as in ``job or urn``. A truthy non-string is not blank."""
|
|
93
|
+
if isinstance(value, str):
|
|
94
|
+
return value.strip(_ASCII_WHITESPACE) == ""
|
|
95
|
+
return not value
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _warn_dropped(pointer: str) -> None:
|
|
99
|
+
logger.warning(
|
|
100
|
+
"Dropped forbidden envelope key %s from the envelope "
|
|
101
|
+
"(non-canonical, message-envelope.md §10).",
|
|
102
|
+
pointer,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _first_encode_violation(envelope: Mapping[str, Any]) -> Optional[str]:
|
|
107
|
+
"""RFC 6901 pointer of the first key encode refuses to write, or ``None``."""
|
|
108
|
+
for container, key in FORBIDDEN_KEYS:
|
|
109
|
+
target = envelope if container is None else envelope.get(container)
|
|
110
|
+
if isinstance(target, Mapping) and key in target:
|
|
111
|
+
return "/" + key if container is None else f"/{container}/{key}"
|
|
112
|
+
if "urn" in envelope:
|
|
113
|
+
return "/urn"
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
83
117
|
def _has_forbidden_keys(envelope: Mapping[str, Any]) -> bool:
|
|
84
118
|
for container, key in FORBIDDEN_KEYS:
|
|
85
119
|
target = envelope if container is None else envelope.get(container)
|
|
@@ -101,12 +135,18 @@ class EnvelopeCodec:
|
|
|
101
135
|
*,
|
|
102
136
|
queue: str = "default",
|
|
103
137
|
trace_id: Optional[str] = None,
|
|
138
|
+
meta: Optional[Mapping[str, Any]] = None,
|
|
104
139
|
) -> Dict[str, Any]:
|
|
105
140
|
"""Build the canonical envelope for a ``(urn, data)`` pair.
|
|
106
141
|
|
|
107
142
|
``trace_id`` is reused when given (trace continuation), otherwise a fresh
|
|
108
143
|
UUID is minted. ``attempts`` starts at 0 and is the top-level transport
|
|
109
144
|
counter (kept out of the immutable ``meta`` block).
|
|
145
|
+
|
|
146
|
+
``meta`` (ADR-0031 (b)) adds producer-supplied entries (e.g. ``{"ext": {"acme": 1}}``) to
|
|
147
|
+
the ``meta`` block next to the core keys. An entry whose key is a core ``meta`` key
|
|
148
|
+
(``id``, ``queue``, ``lang``, ``schema_version``, ``created_at``) or a forbidden ``meta``
|
|
149
|
+
key (message-envelope.md §10) raises :class:`~babelqueue.exceptions.EnvelopeKeyError`.
|
|
110
150
|
"""
|
|
111
151
|
urn = (urn or "").strip()
|
|
112
152
|
if not urn:
|
|
@@ -117,19 +157,36 @@ class EnvelopeCodec:
|
|
|
117
157
|
|
|
118
158
|
trace_id = (trace_id or "").strip() or str(uuid.uuid4())
|
|
119
159
|
|
|
120
|
-
|
|
160
|
+
extra: Dict[str, Any] = {}
|
|
161
|
+
if meta is not None:
|
|
162
|
+
if not isinstance(meta, Mapping):
|
|
163
|
+
raise BabelQueueError("The producer meta must be a mapping of string keys to values.")
|
|
164
|
+
forbidden_meta = {key for container, key in FORBIDDEN_KEYS if container == "meta"}
|
|
165
|
+
for key, value in meta.items():
|
|
166
|
+
if key in CORE_META_KEYS or key in forbidden_meta:
|
|
167
|
+
raise EnvelopeKeyError(
|
|
168
|
+
f"/meta/{key}",
|
|
169
|
+
f"The producer meta key /meta/{key} is reserved or forbidden "
|
|
170
|
+
"(core meta keys and message-envelope.md §10 keys cannot be set).",
|
|
171
|
+
)
|
|
172
|
+
extra[key] = value
|
|
173
|
+
|
|
174
|
+
meta_block: Dict[str, Any] = {
|
|
175
|
+
"id": str(uuid.uuid4()),
|
|
176
|
+
"queue": queue,
|
|
177
|
+
"lang": SOURCE_LANG,
|
|
178
|
+
"schema_version": SCHEMA_VERSION,
|
|
179
|
+
"created_at": int(time.time() * 1000),
|
|
180
|
+
}
|
|
181
|
+
meta_block.update(extra)
|
|
182
|
+
envelope: Dict[str, Any] = {
|
|
121
183
|
"job": urn,
|
|
122
184
|
"trace_id": trace_id,
|
|
123
185
|
"data": dict(data),
|
|
124
|
-
"meta":
|
|
125
|
-
"id": str(uuid.uuid4()),
|
|
126
|
-
"queue": queue,
|
|
127
|
-
"lang": SOURCE_LANG,
|
|
128
|
-
"schema_version": SCHEMA_VERSION,
|
|
129
|
-
"created_at": int(time.time() * 1000),
|
|
130
|
-
},
|
|
186
|
+
"meta": meta_block,
|
|
131
187
|
"attempts": 0,
|
|
132
188
|
}
|
|
189
|
+
return envelope
|
|
133
190
|
|
|
134
191
|
@staticmethod
|
|
135
192
|
def from_message(message: Any, queue: str = "default") -> Dict[str, Any]:
|
|
@@ -152,20 +209,20 @@ class EnvelopeCodec:
|
|
|
152
209
|
def encode(envelope: Mapping[str, Any]) -> str:
|
|
153
210
|
"""Encode the envelope as compact UTF-8 JSON (unescaped unicode/slashes).
|
|
154
211
|
|
|
155
|
-
Forbidden keys (message-envelope.md §10) are never emitted:
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
from a legacy source.
|
|
212
|
+
Forbidden keys (message-envelope.md §10) are never emitted: an envelope that carries one
|
|
213
|
+
(top-level ``timestamp``; ``meta.max_retries`` / ``meta.attempts`` / ``meta.source`` /
|
|
214
|
+
``meta.ts``) or the inbound-only top-level ``urn`` alias raises
|
|
215
|
+
:class:`~babelqueue.exceptions.EnvelopeKeyError` (ADR-0031 (c)) and nothing is written.
|
|
216
|
+
Envelopes produced by :meth:`decode` are always safe to re-encode: decode already drops
|
|
217
|
+
those keys. Unknown, non-forbidden keys are kept as-is.
|
|
162
218
|
"""
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
219
|
+
pointer = _first_encode_violation(envelope)
|
|
220
|
+
if pointer is not None:
|
|
221
|
+
raise EnvelopeKeyError(
|
|
222
|
+
pointer,
|
|
223
|
+
f"Refusing to encode the forbidden envelope key {pointer} "
|
|
224
|
+
"(non-canonical, message-envelope.md §10).",
|
|
225
|
+
)
|
|
169
226
|
return json.dumps(envelope, ensure_ascii=False, separators=(",", ":"))
|
|
170
227
|
|
|
171
228
|
@staticmethod
|
|
@@ -175,15 +232,33 @@ class EnvelopeCodec:
|
|
|
175
232
|
Unknown keys are kept as-is (forward compatibility). Forbidden keys
|
|
176
233
|
(message-envelope.md §10) are dropped and reported as warnings on :data:`logger`
|
|
177
234
|
(policy K-15: warn, never reject), so re-encoding never re-emits them.
|
|
235
|
+
|
|
236
|
+
The ``urn`` alias is folded into ``job`` (ADR-0031 (c)): when ``job`` is missing, empty or
|
|
237
|
+
only ASCII whitespace, the ``urn`` value becomes ``job`` (silently); when both carry a
|
|
238
|
+
value, ``job`` wins and ``urn`` is dropped with a warning naming ``/urn``. The decoded
|
|
239
|
+
envelope never keeps a ``urn`` key.
|
|
178
240
|
"""
|
|
179
241
|
decoded = parse_envelope(raw)
|
|
180
242
|
strip_forbidden_keys(decoded)
|
|
243
|
+
if "urn" in decoded:
|
|
244
|
+
if _is_blank(decoded.get("job")):
|
|
245
|
+
decoded["job"] = decoded.pop("urn")
|
|
246
|
+
else:
|
|
247
|
+
del decoded["urn"]
|
|
248
|
+
_warn_dropped("/urn")
|
|
181
249
|
return decoded
|
|
182
250
|
|
|
183
251
|
@staticmethod
|
|
184
252
|
def urn(envelope: Mapping[str, Any]) -> str:
|
|
185
|
-
"""The message URN: canonical ``job``, with ``urn`` accepted as an alias.
|
|
186
|
-
|
|
253
|
+
"""The message URN: canonical ``job``, with ``urn`` accepted as an alias.
|
|
254
|
+
|
|
255
|
+
A blank ``job`` (missing, empty or only ASCII whitespace) falls back to ``urn``.
|
|
256
|
+
"""
|
|
257
|
+
job = envelope.get("job")
|
|
258
|
+
if isinstance(job, str) and not _is_blank(job):
|
|
259
|
+
return job
|
|
260
|
+
alias = envelope.get("urn")
|
|
261
|
+
return alias if isinstance(alias, str) and not _is_blank(alias) else ""
|
|
187
262
|
|
|
188
263
|
@staticmethod
|
|
189
264
|
def accepts(envelope: Mapping[str, Any]) -> bool:
|
|
@@ -7,6 +7,21 @@ class BabelQueueError(Exception):
|
|
|
7
7
|
"""Base for every recoverable BabelQueue error (bad config, empty URN, ...)."""
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
class EnvelopeKeyError(BabelQueueError):
|
|
11
|
+
"""An envelope key the codec refuses to write (ADR-0031 (b), (c)).
|
|
12
|
+
|
|
13
|
+
Raised by :meth:`~babelqueue.codec.EnvelopeCodec.make` when the ``meta`` entries collide with a
|
|
14
|
+
core ``meta`` key (``id``, ``queue``, ``lang``, ``schema_version``, ``created_at``) or carry a
|
|
15
|
+
forbidden key, and by :meth:`~babelqueue.codec.EnvelopeCodec.encode` when the envelope holds a
|
|
16
|
+
forbidden key of message-envelope.md section 10 (including the top-level ``urn`` alias).
|
|
17
|
+
``pointer`` is the RFC 6901 pointer of the first offending key (e.g. ``"/meta/ts"``).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, pointer: str, message: str) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.pointer = pointer
|
|
23
|
+
|
|
24
|
+
|
|
10
25
|
class UnknownUrnError(BabelQueueError):
|
|
11
26
|
"""A consumed message carries a URN with no mapped handler (strategy "fail")."""
|
|
12
27
|
|