babelqueue 1.17.0__tar.gz → 1.19.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.17.0 → babelqueue-1.19.0}/CHANGELOG.md +75 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/PKG-INFO +1 -1
- {babelqueue-1.17.0 → babelqueue-1.19.0}/pyproject.toml +1 -1
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/__init__.py +3 -1
- babelqueue-1.19.0/src/babelqueue/_envelope_shape.py +153 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/app.py +9 -0
- babelqueue-1.19.0/src/babelqueue/kafka_transport.py +721 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/redrive.py +43 -12
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/transport.py +52 -1
- babelqueue-1.19.0/tests/conformance/CONFORMANCE_VERSION +1 -0
- babelqueue-1.19.0/tests/conformance/fixtures/duplicate-keys-last-wins.json +19 -0
- babelqueue-1.19.0/tests/conformance/fixtures/invalid-wrong-case-job.json +15 -0
- babelqueue-1.19.0/tests/conformance/fixtures/wrong-case-keys-roundtrip.json +20 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/manifest.json +33 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/fakes/confluent_kafka/__init__.py +12 -4
- babelqueue-1.19.0/tests/poison-trigger-set.json +392 -0
- babelqueue-1.19.0/tests/test_kafka_integration.py +311 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_kafka_integration_logic.py +1 -1
- babelqueue-1.19.0/tests/test_kafka_transport.py +1793 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_redrive.py +40 -0
- babelqueue-1.17.0/src/babelqueue/kafka_transport.py +0 -429
- babelqueue-1.17.0/tests/conformance/CONFORMANCE_VERSION +0 -1
- babelqueue-1.17.0/tests/test_kafka_integration.py +0 -169
- babelqueue-1.17.0/tests/test_kafka_transport.py +0 -985
- {babelqueue-1.17.0 → babelqueue-1.19.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/.github/dependabot.yml +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/.github/workflows/ci.yml +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/.github/workflows/release.yml +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/.gitignore +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/LICENSE +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/README.md +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/artemis_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/asb_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/gdpr.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/headers.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/idempotency.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/otel.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/outbox.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/pulsar_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/redis_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/replay.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/schema.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/sqs_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/fakes/confluent_kafka/admin.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_app.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_artemis_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_asb_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_behaviour_conformance.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_celery.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_codec.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_conformance.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_django.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_gdpr.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_headers.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_idempotency.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_otel.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_outbox.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_pulsar_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_redis_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_replay.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_schema.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_schema_conformance.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_shutdown.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_sqs_conformance.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_sqs_transport.py +0 -0
|
@@ -9,6 +9,81 @@ The envelope wire format is versioned separately by `meta.schema_version`
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [1.19.0] - 2026-10-12
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- 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.
|
|
16
|
+
- **Kafka: a poison record is dead-lettered wrapped (`dead_letter.reason: "poison"`), like the Go
|
|
17
|
+
reference (§6.5, ADR-0009).** A value that is not valid UTF-8, not valid JSON, not a JSON object, or has
|
|
18
|
+
a member of the wrong type (the exact set is listed in the next entry) used to reach `<topic>.dlq` as its raw
|
|
19
|
+
bytes. It is now written as a body that keeps its keys when it is exactly one JSON object, otherwise
|
|
20
|
+
`{"raw": "<body>"}` (bytes that are not UTF-8 are stored with U+FFFD substitutions), plus a
|
|
21
|
+
`dead_letter` block (`reason: "poison"`, `original_queue`, `attempts` from the `bq-attempts` header, `lang`);
|
|
22
|
+
the record key is preserved. A poison record is still never retried and is dropped when no DLQ is
|
|
23
|
+
configured (the `on_unknown_urn` strategy, `DELETE` included, no longer matters: see below). The version-gate rejection (`bq-schema-version`, §6.7) still
|
|
24
|
+
dead-letters its record raw. The wrap is applied when the runtime writes the DLQ record (not when the
|
|
25
|
+
record is read), so a poison record that `redrive` restores into the DLQ it popped it from stays byte
|
|
26
|
+
for byte, whatever that DLQ is named. `original_queue` is the `bq-original-topic` header when present
|
|
27
|
+
(else the consumed topic), like the Java, .NET and Node consumers; a body whose numbers cannot be
|
|
28
|
+
written back as valid JSON (e.g. `1e400`) is carried as `{"raw": ...}` instead of producing an invalid DLQ body.
|
|
29
|
+
A body that decodes but names no URN is not poison (unknown-URN path, as before). Consumers of the DLQ
|
|
30
|
+
that expected the raw bytes of a malformed record must now read the wrapped body.
|
|
31
|
+
- **Kafka: the poison trigger set now equals what the Go reference's typed `Decode` rejects.** Behaviour
|
|
32
|
+
change; the old Python-only rules (a lenient `attempts` read, `Accepts`-style conformance checks) are gone.
|
|
33
|
+
A record is poison when it is not valid UTF-8, not valid JSON (an empty or whitespace-only body, a BOM,
|
|
34
|
+
trailing data, leading zeros, `NaN` / `Infinity`, single quotes), or not a JSON object (a bare `null` is
|
|
35
|
+
not poison: it is an empty envelope and takes the unknown-URN path), or when a member has the wrong type:
|
|
36
|
+
`job`, `trace_id`, `meta.id|queue|lang` and `dead_letter.reason|error|exception|original_queue|lang`
|
|
37
|
+
must be a string or null; `meta.schema_version`, `meta.created_at`, `attempts` and
|
|
38
|
+
`dead_letter.failed_at|attempts` must be an int64 integer literal or null (a string such as `"3"` or
|
|
39
|
+
`"2026"`, a fraction or exponent such as `3.0` / `1e2`, a bool, an array and an object are poison);
|
|
40
|
+
`data`, `meta` and `dead_letter` must be an object or null; every number inside `data` must be a finite
|
|
41
|
+
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
|
|
42
|
+
is no longer poison, even when it is not a conformant envelope: a non-string `urn` alias, an absent
|
|
43
|
+
`data`, `"attempts": null` (read as 0 and written back as 0) and `meta.schema_version: 2` are routed by URN
|
|
44
|
+
like any other record (Go's consume path never calls `Accepts`). The Kafka transport no longer
|
|
45
|
+
delivers records under the shared non-numeric-`attempts` route; they are plain poison now.
|
|
46
|
+
`redrive` restores a poison-routed record into the DLQ unchanged instead of replaying or re-wrapping it,
|
|
47
|
+
even when its `job` still reads as a URN. The only Python-specific limit is JSON nested deeper than
|
|
48
|
+
the interpreter's recursion limit (about 1000 levels, Go allows 10000), which is treated as poison.
|
|
49
|
+
- 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.
|
|
50
|
+
- **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.
|
|
51
|
+
- **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.
|
|
52
|
+
- **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.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
- Kafka: a wrongly-typed `meta` or `meta.created_at` in a wrapped poison body no longer fails the write
|
|
56
|
+
that settles it (header projection and record timestamp tolerate it).
|
|
57
|
+
|
|
58
|
+
## [1.18.0] - 2026-10-11
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
- `BatchPopper` (optional transport capability, exported from `babelqueue`) with
|
|
62
|
+
`pop_batch(queue, max=0, timeout=1.0)`: take several deliveries at once and hold them un-settled.
|
|
63
|
+
`KafkaTransport` implements it; `redrive` uses it when a transport offers it.
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
- **`redrive` no longer loops forever (default `max=0`) or repeats records (`max>1`) on Kafka.**
|
|
67
|
+
`redrive` drains the DLQ before it processes it, but `KafkaTransport.pop` first seeks back to the
|
|
68
|
+
previous un-acked record, so every pop returned the first record again. The drain now takes the
|
|
69
|
+
batch through `pop_batch` (one rewind up front, none between the polls), and `KafkaTransport`
|
|
70
|
+
tracks several in-flight records: acks are committed in offset order, so the cumulative commit
|
|
71
|
+
never passes a record that is still unsettled, and an abandoned batch is rewound to its lowest
|
|
72
|
+
offset by the next `pop`. The restore writes are not re-read in the same call. Other transports
|
|
73
|
+
and the single-record `pop`/`ack`/`rewind` behaviour are unchanged. No wire change.
|
|
74
|
+
A record whose envelope carries an `attempts` the runtime cannot read as an integer (a foreign
|
|
75
|
+
producer: `null`, `""`, a list/object, a bool, `"three"`, `NaN`/`Infinity`; an absent key is just 0) no longer raises out
|
|
76
|
+
of `pop` or the restore publish. `KafkaTransport.pop` hands it over under a reserved poison route
|
|
77
|
+
(like a non-UTF-8 record), so the runtime dead-letters it raw without running a handler (a failing
|
|
78
|
+
handler can no longer wedge the partition) and `redrive` restores it to the DLQ instead of
|
|
79
|
+
replaying it. The redundant `bq-attempts` header is left out of such a write.
|
|
80
|
+
- **`redrive` now carries the DLQ record key onto the redriven record.** The restore writes back to
|
|
81
|
+
the DLQ already kept the key; the redrive write to the work topic did not, so a replayed Kafka
|
|
82
|
+
record lost its key (and per-key partition affinity). It now goes through
|
|
83
|
+
`publish_keeping_route(..., key=message.key)`, byte-for-byte (null stays null, empty stays empty),
|
|
84
|
+
per contract §6.5 "Record key on republish". Non-keyed transports behave exactly as before; the
|
|
85
|
+
`bypass=True` header path still carries no key. No wire change.
|
|
86
|
+
|
|
12
87
|
## [1.17.0] - 2026-10-11
|
|
13
88
|
|
|
14
89
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: babelqueue
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.19.0
|
|
4
4
|
Summary: Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers.
|
|
5
5
|
Project-URL: Homepage, https://babelqueue.com
|
|
6
6
|
Project-URL: Source, https://github.com/BabelQueue/babelqueue-python
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "babelqueue"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.19.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"
|
|
@@ -30,6 +30,7 @@ from .exceptions import BabelQueueError, DecryptError, UnknownUrnError
|
|
|
30
30
|
from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
|
|
31
31
|
from .routing import UnknownUrnStrategy
|
|
32
32
|
from .transport import (
|
|
33
|
+
BatchPopper,
|
|
33
34
|
HeaderPublisher,
|
|
34
35
|
InMemoryTransport,
|
|
35
36
|
ReceivedMessage,
|
|
@@ -40,7 +41,7 @@ from .transport import (
|
|
|
40
41
|
Transport,
|
|
41
42
|
)
|
|
42
43
|
|
|
43
|
-
__version__ = "1.
|
|
44
|
+
__version__ = "1.19.0"
|
|
44
45
|
|
|
45
46
|
__all__ = [
|
|
46
47
|
"BabelQueue",
|
|
@@ -53,6 +54,7 @@ __all__ = [
|
|
|
53
54
|
"Transport",
|
|
54
55
|
"InMemoryTransport",
|
|
55
56
|
"ReceivedMessage",
|
|
57
|
+
"BatchPopper",
|
|
56
58
|
"HeaderPublisher",
|
|
57
59
|
"Redeliverer",
|
|
58
60
|
"Rewinder",
|
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
_DEAD_LETTER = {
|
|
55
|
+
"reason": _STRING,
|
|
56
|
+
"error": _STRING,
|
|
57
|
+
"exception": _STRING,
|
|
58
|
+
"original_queue": _STRING,
|
|
59
|
+
"lang": _STRING,
|
|
60
|
+
"failed_at": _INTEGER,
|
|
61
|
+
"attempts": _INTEGER,
|
|
62
|
+
}
|
|
63
|
+
_TOP = {
|
|
64
|
+
"job": _STRING,
|
|
65
|
+
"trace_id": _STRING,
|
|
66
|
+
"attempts": _INTEGER,
|
|
67
|
+
"data": _DATA,
|
|
68
|
+
"meta": _OBJECT,
|
|
69
|
+
"dead_letter": _OBJECT,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _reject_constant(name: str) -> Any:
|
|
74
|
+
raise ValueError("invalid JSON constant " + name)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parse_int(literal: str) -> Any:
|
|
78
|
+
return int(literal) if len(literal) <= _MAX_INT_DIGITS else _HUGE
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_finite_number(value: Any) -> bool:
|
|
82
|
+
if value is _HUGE:
|
|
83
|
+
return False
|
|
84
|
+
if isinstance(value, float):
|
|
85
|
+
return math.isfinite(value)
|
|
86
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
87
|
+
try:
|
|
88
|
+
return math.isfinite(float(value))
|
|
89
|
+
except OverflowError:
|
|
90
|
+
return False
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _all_numbers_finite(root: Any) -> bool:
|
|
95
|
+
stack = [root]
|
|
96
|
+
while stack:
|
|
97
|
+
value = stack.pop()
|
|
98
|
+
if isinstance(value, _Pairs):
|
|
99
|
+
stack.extend(item for _, item in value)
|
|
100
|
+
elif isinstance(value, list):
|
|
101
|
+
stack.extend(value)
|
|
102
|
+
elif not _is_finite_number(value):
|
|
103
|
+
return False
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _check_member(kind: str, name: str, value: Any) -> Optional[str]:
|
|
108
|
+
if value is None:
|
|
109
|
+
return None
|
|
110
|
+
if kind == _STRING:
|
|
111
|
+
return None if isinstance(value, str) else "member %r is not a string" % name
|
|
112
|
+
if kind == _INTEGER:
|
|
113
|
+
if type(value) is int and _INT64_MIN <= value <= _INT64_MAX:
|
|
114
|
+
return None
|
|
115
|
+
return "member %r is not an int64 integer" % name
|
|
116
|
+
if not isinstance(value, _Pairs):
|
|
117
|
+
return "member %r is not an object" % name
|
|
118
|
+
if kind == _DATA and not _all_numbers_finite(value):
|
|
119
|
+
return "member %r holds a number that is not a finite double" % name
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _check_object(pairs: _Pairs, kinds: Dict[str, str], prefix: str) -> Optional[str]:
|
|
124
|
+
for key, value in pairs:
|
|
125
|
+
kind = kinds.get(key)
|
|
126
|
+
if kind is None:
|
|
127
|
+
continue
|
|
128
|
+
failure = _check_member(kind, prefix + key, value)
|
|
129
|
+
if failure is not None:
|
|
130
|
+
return failure
|
|
131
|
+
if kind == _OBJECT and value is not None:
|
|
132
|
+
nested = _check_object(value, _META if key == "meta" else _DEAD_LETTER, key + ".")
|
|
133
|
+
if nested is not None:
|
|
134
|
+
return nested
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def envelope_violation(text: str) -> Optional[str]:
|
|
139
|
+
"""Why ``text`` is not a decodable BabelQueue body, or ``None`` when Go's typed decode accepts it."""
|
|
140
|
+
try:
|
|
141
|
+
parsed = json.loads(
|
|
142
|
+
text,
|
|
143
|
+
object_pairs_hook=_Pairs,
|
|
144
|
+
parse_int=_parse_int,
|
|
145
|
+
parse_constant=_reject_constant,
|
|
146
|
+
)
|
|
147
|
+
except (ValueError, RecursionError) as exc:
|
|
148
|
+
return "invalid JSON: " + str(exc)
|
|
149
|
+
if parsed is None:
|
|
150
|
+
return None # a bare ``null`` is an empty envelope, not poison
|
|
151
|
+
if not isinstance(parsed, _Pairs):
|
|
152
|
+
return "the JSON root is not an object"
|
|
153
|
+
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,
|
|
@@ -476,6 +477,14 @@ class BabelQueue:
|
|
|
476
477
|
|
|
477
478
|
def _route_unknown(self, urn: str, received: ReceivedMessage, envelope: Mapping[str, Any]) -> None:
|
|
478
479
|
strategy = self.on_unknown_urn
|
|
480
|
+
# A Kafka poison record (§6.5: not valid UTF-8 / not a decodable body) is not an unknown URN: it
|
|
481
|
+
# never decoded into an envelope, so ``on_unknown_urn`` has no say. It is checked before the
|
|
482
|
+
# DELETE strategy and settled like the Go reference does -- dead-lettered (wrapped, reason
|
|
483
|
+
# ``poison``) and committed, or dropped only when no DLQ is configured. Other transports'
|
|
484
|
+
# version-gated deliveries keep their existing order below.
|
|
485
|
+
if isinstance(received.route_urn, PoisonRoute):
|
|
486
|
+
self._settle_version_gated(received)
|
|
487
|
+
return
|
|
479
488
|
if strategy == UnknownUrnStrategy.DELETE:
|
|
480
489
|
self._ack_settled(received)
|
|
481
490
|
return
|