babelqueue 1.18.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.18.0 → babelqueue-1.19.0}/CHANGELOG.md +46 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/PKG-INFO +1 -1
- {babelqueue-1.18.0 → babelqueue-1.19.0}/pyproject.toml +1 -1
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/__init__.py +1 -1
- babelqueue-1.19.0/src/babelqueue/_envelope_shape.py +153 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/app.py +9 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/kafka_transport.py +176 -53
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/redrive.py +6 -2
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/transport.py +27 -0
- 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.18.0 → babelqueue-1.19.0}/tests/conformance/manifest.json +33 -0
- babelqueue-1.19.0/tests/poison-trigger-set.json +392 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_kafka_transport.py +428 -43
- babelqueue-1.18.0/tests/conformance/CONFORMANCE_VERSION +0 -1
- {babelqueue-1.18.0 → babelqueue-1.19.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/.github/dependabot.yml +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/.github/workflows/ci.yml +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/.github/workflows/release.yml +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/.gitignore +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/LICENSE +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/README.md +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/artemis_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/asb_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/gdpr.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/headers.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/idempotency.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/otel.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/outbox.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/pulsar_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/redis_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/replay.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/schema.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/sqs_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/fakes/confluent_kafka/__init__.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/fakes/confluent_kafka/admin.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_app.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_artemis_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_asb_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_behaviour_conformance.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_celery.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_codec.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_conformance.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_django.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_gdpr.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_headers.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_idempotency.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_kafka_integration.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_kafka_integration_logic.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_otel.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_outbox.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_pulsar_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_redis_transport.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_redrive.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_replay.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_schema.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_schema_conformance.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_shutdown.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_sqs_conformance.py +0 -0
- {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_sqs_transport.py +0 -0
|
@@ -9,6 +9,52 @@ 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
|
+
|
|
12
58
|
## [1.18.0] - 2026-10-11
|
|
13
59
|
|
|
14
60
|
### 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"
|
|
@@ -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
|
|
@@ -19,10 +19,18 @@ Settlement writes (retry republish, DLQ) go through ``publish_settle`` and wait
|
|
|
19
19
|
delivery report (``delivery_timeout``); a failure raises ``KafkaDeliveryError`` and the offset is
|
|
20
20
|
not committed. ``publish`` itself stays fire-and-forget. Those writes also carry the consumed
|
|
21
21
|
record's key unchanged (``ReceivedMessage.key``; null stays null, empty stays empty, never
|
|
22
|
-
synthesised). A record value that is not valid UTF-8
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
synthesised). A poison record (§6.5: a value that is not valid UTF-8, not valid JSON, not a JSON
|
|
23
|
+
object other than ``null``, or a member of the wrong type, member names matched exactly as spelled: what the Go
|
|
24
|
+
reference's typed ``Decode`` rejects, see :mod:`babelqueue._envelope_shape`) is handed to the runtime under a
|
|
25
|
+
reserved route and dead-lettered directly, never retried, *wrapped* like the Go reference: a body that
|
|
26
|
+
is one JSON object keeps its keys, anything else is carried as ``{"raw": "<body>"}``, and a
|
|
27
|
+
``dead_letter`` block with ``reason: "poison"`` is added (the key is preserved; with the DLQ off it is
|
|
28
|
+
dropped and committed; ``on_unknown_urn``, ``DELETE`` included, has no say). The record is handed over raw under a
|
|
29
|
+
``PoisonRoute`` and wrapped by ``publish_settle`` only when it is written to a queue other than the one
|
|
30
|
+
it was read from, so a ``redrive`` restore into the DLQ it popped it from (any DLQ name) is byte for
|
|
31
|
+
byte and never double-wraps. The version gate (§6.7) still
|
|
32
|
+
dead-letters its record raw. The runtime logs a poison record as a version-gated message because it
|
|
33
|
+
reuses that settle path; the route in the log names it.
|
|
26
34
|
|
|
27
35
|
This implements §6 of the broker-bindings contract. The envelope is unchanged
|
|
28
36
|
(``schema_version`` stays 1); Apache Kafka is purely additive.
|
|
@@ -34,15 +42,18 @@ or ``KafkaTransport(producer=..., consumer_factory=...)``.
|
|
|
34
42
|
|
|
35
43
|
from __future__ import annotations
|
|
36
44
|
|
|
45
|
+
import json
|
|
37
46
|
import logging
|
|
38
47
|
import re
|
|
39
48
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
40
49
|
from urllib.parse import quote
|
|
41
50
|
|
|
51
|
+
from . import dead_letter
|
|
42
52
|
from .codec import SCHEMA_VERSION, EnvelopeCodec, parse_envelope
|
|
53
|
+
from ._envelope_shape import envelope_violation
|
|
43
54
|
from .exceptions import BabelQueueError
|
|
44
55
|
from .transport import (
|
|
45
|
-
|
|
56
|
+
PoisonRoute,
|
|
46
57
|
ReceivedMessage,
|
|
47
58
|
Transport,
|
|
48
59
|
truncate_declared_version,
|
|
@@ -54,6 +65,8 @@ _logger = logging.getLogger("babelqueue.kafka")
|
|
|
54
65
|
#: same set in every SDK (GR-5). Deliberately not ``str.strip()``, which also strips U+001C-U+001F,
|
|
55
66
|
#: U+0085, U+00A0, U+2028, U+3000 ...
|
|
56
67
|
_BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
|
|
68
|
+
#: Every surrogate code point except U+DC80..U+DCFF (those are ``surrogateescape``d non-UTF-8 bytes).
|
|
69
|
+
_LONE_SURROGATE = re.compile("[\ud800-\udbff\udc00-\udc7f\udd00-\udfff]")
|
|
57
70
|
#: Prefix of the reserved ``route_urn`` a version-gated message is delivered under. It is not a
|
|
58
71
|
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime dead-letters it
|
|
59
72
|
#: directly (never retried, never decoded; the DLQ record is the incoming raw body). The
|
|
@@ -67,6 +80,13 @@ UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
|
|
|
67
80
|
#: :meth:`KafkaTransport.publish` encodes back to the exact original bytes.
|
|
68
81
|
NON_UTF8_ROUTE = UNSUPPORTED_SCHEMA_ROUTE_PREFIX + "non-utf8-payload"
|
|
69
82
|
|
|
83
|
+
#: Reserved ``route_urn`` of a record whose value is not a decodable BabelQueue body (invalid JSON, a
|
|
84
|
+
#: non-object root, a member of the wrong type). Like the gated routes it is dead-lettered directly and
|
|
85
|
+
#: never retried, but the DLQ record is the *wrapped* poison body built by
|
|
86
|
+
#: :meth:`KafkaTransport._poison_body` (``dead_letter.reason == "poison"``), not the raw bytes.
|
|
87
|
+
POISON_ROUTE = UNSUPPORTED_SCHEMA_ROUTE_PREFIX + "poison"
|
|
88
|
+
|
|
89
|
+
|
|
70
90
|
#: How long (seconds) a settle-path publish waits for the broker to confirm the write.
|
|
71
91
|
DEFAULT_DELIVERY_TIMEOUT = 30.0
|
|
72
92
|
|
|
@@ -79,10 +99,13 @@ class KafkaDeliveryError(BabelQueueError):
|
|
|
79
99
|
|
|
80
100
|
def _runtime_attempts(env: Any) -> int:
|
|
81
101
|
"""The envelope's ``attempts`` exactly as the runtime's retry path reads it
|
|
82
|
-
(``int(envelope.get("attempts", 0))``): an absent key is 0
|
|
83
|
-
value that read would choke on —
|
|
84
|
-
for a bool, which is no counter at all although ``int()``
|
|
102
|
+
(``int(envelope.get("attempts", 0))``): an absent key or a null is 0 (Go decodes a null integer
|
|
103
|
+
as zero). Raises ``ValueError`` for every value that read would choke on — ``""``, a list/dict,
|
|
104
|
+
``"three"``, ``inf``/``nan`` — and for a bool, which is no counter at all although ``int()``
|
|
105
|
+
happens to accept it."""
|
|
85
106
|
value = env.get("attempts", 0)
|
|
107
|
+
if value is None:
|
|
108
|
+
return 0
|
|
86
109
|
if isinstance(value, bool):
|
|
87
110
|
raise ValueError("attempts is a bool")
|
|
88
111
|
try:
|
|
@@ -91,6 +114,11 @@ def _runtime_attempts(env: Any) -> int:
|
|
|
91
114
|
raise ValueError("attempts is not an integer") from exc
|
|
92
115
|
|
|
93
116
|
|
|
117
|
+
def _reject_json_constant(name: str) -> Any:
|
|
118
|
+
"""``json.loads`` hook: ``NaN`` / ``Infinity`` are not JSON, so a body carrying them is undecodable."""
|
|
119
|
+
raise ValueError("invalid JSON constant " + name)
|
|
120
|
+
|
|
121
|
+
|
|
94
122
|
def _brokers_from_url(url: str) -> str:
|
|
95
123
|
rest = url.split("://", 1)[1] if "://" in url else url
|
|
96
124
|
return rest.split("/", 1)[0] or "localhost:9092"
|
|
@@ -166,7 +194,11 @@ class KafkaTransport(Transport):
|
|
|
166
194
|
env = parse_envelope(body)
|
|
167
195
|
if not env:
|
|
168
196
|
return []
|
|
169
|
-
meta = env.get("meta")
|
|
197
|
+
meta = env.get("meta")
|
|
198
|
+
if not isinstance(meta, dict):
|
|
199
|
+
# A wrongly-typed ``meta`` (a poison body keeps its keys when dead-lettered, §6.5) has no
|
|
200
|
+
# header view; it must not fail the write that settles the record.
|
|
201
|
+
meta = {}
|
|
170
202
|
|
|
171
203
|
headers: Headers = []
|
|
172
204
|
|
|
@@ -217,7 +249,7 @@ class KafkaTransport(Transport):
|
|
|
217
249
|
except (ValueError, TypeError):
|
|
218
250
|
pass
|
|
219
251
|
break
|
|
220
|
-
if attempts == body_attempts:
|
|
252
|
+
if attempts == body_attempts and env.get("attempts", 0) is not None:
|
|
221
253
|
return body
|
|
222
254
|
env["attempts"] = attempts
|
|
223
255
|
return EnvelopeCodec.encode(env)
|
|
@@ -265,18 +297,89 @@ class KafkaTransport(Transport):
|
|
|
265
297
|
return key_of() if callable(key_of) else None
|
|
266
298
|
|
|
267
299
|
@staticmethod
|
|
268
|
-
def
|
|
269
|
-
"""
|
|
270
|
-
|
|
271
|
-
``
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
300
|
+
def _decode_failure(text: str) -> Optional[str]:
|
|
301
|
+
"""Why ``text`` is not a decodable BabelQueue body, or ``None`` when it is.
|
|
302
|
+
|
|
303
|
+
The core decoder is lenient (``decode`` returns ``{}`` for a malformed or non-object body),
|
|
304
|
+
so poison is detected explicitly, with the trigger set of the Go reference's typed
|
|
305
|
+
``Decode`` but case-sensitive member names, as the core decoder reads them (see :mod:`babelqueue._envelope_shape`). A well-typed but non-conformant envelope
|
|
306
|
+
is not poison: it is routed by its URN, as in Go.
|
|
307
|
+
|
|
308
|
+
The shape scan keeps unknown members out of its way, but the core decoder parses the whole
|
|
309
|
+
body with plain ``json.loads`` and turns a ``ValueError`` into an EMPTY envelope (on Python
|
|
310
|
+
3.11+, e.g. an unknown member holding an integer of more than 4300 digits). Such a body is
|
|
311
|
+
poison here too, so content is never silently dropped on the way to a handler."""
|
|
312
|
+
violation = envelope_violation(text)
|
|
313
|
+
if violation is not None:
|
|
314
|
+
return violation
|
|
275
315
|
try:
|
|
276
|
-
|
|
277
|
-
except ValueError:
|
|
278
|
-
return
|
|
279
|
-
return
|
|
316
|
+
json.loads(text)
|
|
317
|
+
except ValueError as exc:
|
|
318
|
+
return "the core decoder cannot parse the body: " + str(exc)
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
def _header_value(headers: Any, wanted: str) -> Optional[str]:
|
|
323
|
+
"""The text of the first record header named ``wanted`` (``None`` when absent)."""
|
|
324
|
+
for key, value in headers or []:
|
|
325
|
+
name = key.decode("utf-8", "replace") if isinstance(key, (bytes, bytearray)) else key
|
|
326
|
+
if name != wanted:
|
|
327
|
+
continue
|
|
328
|
+
if isinstance(value, (bytes, bytearray)):
|
|
329
|
+
return bytes(value).decode("utf-8", "replace")
|
|
330
|
+
return None if value is None else str(value)
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
@classmethod
|
|
334
|
+
def _header_attempts(cls, headers: Any) -> int:
|
|
335
|
+
"""The ``bq-attempts`` header as a non-negative int (0 when absent or unparseable)."""
|
|
336
|
+
raw = cls._header_value(headers, "bq-attempts")
|
|
337
|
+
if raw is None:
|
|
338
|
+
return 0
|
|
339
|
+
try:
|
|
340
|
+
return max(int(raw), 0)
|
|
341
|
+
except (ValueError, TypeError):
|
|
342
|
+
return 0
|
|
343
|
+
|
|
344
|
+
@classmethod
|
|
345
|
+
def _poison_body(cls, text: str, cause: str, original_queue: str, attempts: int) -> str:
|
|
346
|
+
"""The DLQ body of a poison record, in the form of the Go reference (§6.5, ADR-0009): a body
|
|
347
|
+
that is exactly one JSON object keeps its keys, anything else is carried as the string value
|
|
348
|
+
of ``raw`` (bytes that are not UTF-8 are stored with U+FFFD substitutions); a ``dead_letter``
|
|
349
|
+
block with ``reason: "poison"`` replaces any existing one. A body whose keys cannot be written
|
|
350
|
+
back as valid JSON (a number past the double range such as ``1e400``) falls back to ``raw``."""
|
|
351
|
+
# Bytes that are not UTF-8 are carried as surrogateescape code points; the DLQ body must be
|
|
352
|
+
# encodable, so they become U+FFFD (the Go reference stores the lossy conversion too).
|
|
353
|
+
text = text.encode("utf-8", "surrogateescape").decode("utf-8", "replace")
|
|
354
|
+
try:
|
|
355
|
+
parsed = json.loads(text, parse_constant=_reject_json_constant)
|
|
356
|
+
except (ValueError, RecursionError):
|
|
357
|
+
parsed = None
|
|
358
|
+
candidates: List[Dict[str, Any]] = []
|
|
359
|
+
if isinstance(parsed, dict):
|
|
360
|
+
parsed.pop("dead_letter", None)
|
|
361
|
+
candidates.append(parsed)
|
|
362
|
+
candidates.append({"raw": text})
|
|
363
|
+
for record in candidates:
|
|
364
|
+
annotated = dead_letter.annotate(
|
|
365
|
+
record,
|
|
366
|
+
"poison",
|
|
367
|
+
original_queue,
|
|
368
|
+
attempts,
|
|
369
|
+
error="Undecodable BabelQueue body: " + cause,
|
|
370
|
+
exception=BabelQueueError.__name__,
|
|
371
|
+
)
|
|
372
|
+
try:
|
|
373
|
+
serialised = json.dumps(
|
|
374
|
+
annotated, ensure_ascii=False, separators=(",", ":"), allow_nan=False
|
|
375
|
+
)
|
|
376
|
+
# A lone surrogate (a ``\ud800`` escape in a key or value) serialises but cannot be
|
|
377
|
+
# written to the wire as UTF-8: fall back to the next candidate.
|
|
378
|
+
serialised.encode("utf-8")
|
|
379
|
+
return serialised
|
|
380
|
+
except (ValueError, RecursionError):
|
|
381
|
+
continue
|
|
382
|
+
raise AssertionError("unreachable: the raw fallback is always serialisable") # pragma: no cover
|
|
280
383
|
|
|
281
384
|
@staticmethod
|
|
282
385
|
def _is_non_utf8(message: Any) -> bool:
|
|
@@ -308,6 +411,11 @@ class KafkaTransport(Transport):
|
|
|
308
411
|
``key`` is the consumed record's key (:attr:`ReceivedMessage.key`): it is written back
|
|
309
412
|
unchanged, so a retry or DLQ record keeps the original key (``None`` stays a null key, an
|
|
310
413
|
empty key stays empty, and no key is ever synthesised). §6."""
|
|
414
|
+
if isinstance(route_urn, PoisonRoute) and queue != route_urn.queue:
|
|
415
|
+
# The runtime dead-letters a poison record (§6.5): the DLQ body is the Go-form wrap.
|
|
416
|
+
body = self._poison_body(
|
|
417
|
+
body, route_urn.cause, route_urn.original_queue, route_urn.attempts
|
|
418
|
+
)
|
|
311
419
|
kwargs = self._record_kwargs(body, raw_ok=True)
|
|
312
420
|
if key is not None:
|
|
313
421
|
kwargs["key"] = key
|
|
@@ -325,11 +433,22 @@ class KafkaTransport(Transport):
|
|
|
325
433
|
except UnicodeEncodeError:
|
|
326
434
|
if not raw_ok:
|
|
327
435
|
raise
|
|
328
|
-
|
|
436
|
+
# Bytes that were not UTF-8 are carried as ``surrogateescape`` code points and restored
|
|
437
|
+
# byte for byte. A *lone* surrogate (a ``\\ud800`` JSON escape that survived the decode) is
|
|
438
|
+
# not restorable: it becomes U+FFFD, as the Go reference does when it parses the escape.
|
|
439
|
+
# Without this the settle write would raise and the record would be redelivered forever.
|
|
440
|
+
cleaned = _LONE_SURROGATE.sub("\ufffd", body)
|
|
441
|
+
return {"value": cleaned.encode("utf-8", "surrogateescape"), "headers": []}
|
|
329
442
|
kwargs: Dict[str, Any] = {"value": body.encode("utf-8"), "headers": cls._projection(body)}
|
|
330
|
-
|
|
443
|
+
meta = parse_envelope(body).get("meta")
|
|
444
|
+
created_at = meta.get("created_at") if isinstance(meta, dict) else None
|
|
331
445
|
if created_at:
|
|
332
|
-
|
|
446
|
+
try:
|
|
447
|
+
kwargs["timestamp"] = int(created_at)
|
|
448
|
+
except (ValueError, TypeError, OverflowError):
|
|
449
|
+
# A poison body keeps its keys when wrapped (§6.5), so ``created_at`` can be anything:
|
|
450
|
+
# a record timestamp is optional and must not fail the write that settles it.
|
|
451
|
+
pass
|
|
333
452
|
return kwargs
|
|
334
453
|
|
|
335
454
|
def _produce_confirmed(self, producer: Any, queue: str, kwargs: Dict[str, Any]) -> None:
|
|
@@ -421,45 +540,49 @@ class KafkaTransport(Transport):
|
|
|
421
540
|
)
|
|
422
541
|
self._pending.setdefault(queue, []).append(message)
|
|
423
542
|
return received
|
|
424
|
-
if self._is_non_utf8(message):
|
|
425
|
-
# The value cannot be decoded, so there is no envelope to retry or annotate: hand it
|
|
426
|
-
# over under the reserved route; the runtime dead-letters the raw bytes directly (and
|
|
427
|
-
# does not commit if that write is not confirmed) instead of letting ``pop`` raise.
|
|
428
|
-
_logger.warning(
|
|
429
|
-
"Kafka record on %r is not valid UTF-8; dead-lettering its raw bytes.", queue
|
|
430
|
-
)
|
|
431
|
-
received = ReceivedMessage(
|
|
432
|
-
body=self._payload(message),
|
|
433
|
-
queue=queue,
|
|
434
|
-
handle=message,
|
|
435
|
-
route_urn=NON_UTF8_ROUTE,
|
|
436
|
-
key=self._key(message),
|
|
437
|
-
)
|
|
438
|
-
self._pending.setdefault(queue, []).append(message)
|
|
439
|
-
return received
|
|
440
543
|
payload = self._payload(message)
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
544
|
+
non_utf8 = self._is_non_utf8(message)
|
|
545
|
+
cause: Optional[str]
|
|
546
|
+
if non_utf8:
|
|
547
|
+
cause = "the value is not valid UTF-8"
|
|
548
|
+
else:
|
|
549
|
+
try:
|
|
550
|
+
cause = self._decode_failure(payload)
|
|
551
|
+
except Exception as exc: # noqa: BLE001 - e.g. RecursionError on a pathologically nested body
|
|
552
|
+
cause = "the body could not be scanned (%s)" % type(exc).__name__
|
|
553
|
+
body = payload
|
|
554
|
+
if cause is None:
|
|
555
|
+
try:
|
|
556
|
+
body = self._reconcile(payload, headers)
|
|
557
|
+
except Exception as exc: # noqa: BLE001 - any decode failure is poison, never a wedged partition
|
|
558
|
+
cause = "the body could not be decoded (%s)" % type(exc).__name__
|
|
559
|
+
if cause is not None:
|
|
560
|
+
# §6.5 poison: there is no envelope to retry or hand to a handler, so the record is handed
|
|
561
|
+
# over, raw, under a reserved route and dead-lettered directly (never retried; dropped when
|
|
562
|
+
# no DLQ is configured). The route carries what ``publish_settle`` needs to wrap the DLQ
|
|
563
|
+
# body like the Go reference (``dead_letter.reason == "poison"``, the original key); a
|
|
564
|
+
# restore to the queue the record was read from (``redrive``) stays byte for byte.
|
|
565
|
+
_logger.warning("Kafka record on %r is poison (%s); dead-lettering it.", queue, cause)
|
|
566
|
+
base = NON_UTF8_ROUTE if non_utf8 else POISON_ROUTE
|
|
449
567
|
received = ReceivedMessage(
|
|
450
568
|
body=payload,
|
|
451
569
|
queue=queue,
|
|
452
570
|
handle=message,
|
|
453
|
-
route_urn=
|
|
571
|
+
route_urn=PoisonRoute(
|
|
572
|
+
base,
|
|
573
|
+
queue,
|
|
574
|
+
self._header_value(headers, "bq-original-topic") or queue,
|
|
575
|
+
self._header_attempts(headers),
|
|
576
|
+
cause,
|
|
577
|
+
),
|
|
454
578
|
key=self._key(message),
|
|
455
579
|
)
|
|
456
580
|
self._pending.setdefault(queue, []).append(message)
|
|
457
581
|
return received
|
|
458
|
-
body = self._reconcile(payload, headers)
|
|
459
582
|
received = ReceivedMessage(body=body, queue=queue, handle=message, key=self._key(message))
|
|
460
|
-
# Track the record as in flight only once ``pop`` has built it.
|
|
461
|
-
#
|
|
462
|
-
# seek back to it and fail identically, wedging the partition forever.
|
|
583
|
+
# Track the record as in flight only once ``pop`` has built it. Any decode error above is
|
|
584
|
+
# routed as poison instead of escaping: an escape would leave the record uncommitted, so the
|
|
585
|
+
# next ``pop`` would seek back to it and fail identically, wedging the partition forever.
|
|
463
586
|
self._pending.setdefault(queue, []).append(message)
|
|
464
587
|
return received
|
|
465
588
|
|
|
@@ -40,6 +40,7 @@ from .transport import (
|
|
|
40
40
|
NON_NUMERIC_ATTEMPTS_ROUTE,
|
|
41
41
|
BatchPopper,
|
|
42
42
|
HeaderPublisher,
|
|
43
|
+
PoisonRoute,
|
|
43
44
|
ReceivedMessage,
|
|
44
45
|
Transport,
|
|
45
46
|
publish_keeping_route,
|
|
@@ -97,12 +98,15 @@ def redrive(
|
|
|
97
98
|
break
|
|
98
99
|
popped.append(message)
|
|
99
100
|
batch: List[Tuple[ReceivedMessage, Optional[Dict[str, Any]]]] = [
|
|
100
|
-
(message, _decoded(message.body))
|
|
101
|
+
(message, None if isinstance(message.route_urn, PoisonRoute) else _decoded(message.body))
|
|
102
|
+
for message in popped
|
|
101
103
|
]
|
|
102
104
|
|
|
103
105
|
result = RedriveResult()
|
|
104
106
|
for message, envelope in batch:
|
|
105
|
-
# A record the transport flagged as
|
|
107
|
+
# A record the transport flagged as poison (``PoisonRoute``: it never decoded into an envelope,
|
|
108
|
+
# whatever its ``job`` looks like) is restored to the DLQ as-is rather than replayed or re-wrapped,
|
|
109
|
+
# like the Go reference. A record the transport flagged as non-numeric-``attempts`` is not redrivable either: a
|
|
106
110
|
# replay would reset it to a valid counter and quietly repair a foreign producer's bad
|
|
107
111
|
# envelope, so it is restored to the DLQ like any other poison body.
|
|
108
112
|
if envelope is None or message.route_urn == NON_NUMERIC_ATTEMPTS_ROUTE:
|
|
@@ -50,6 +50,33 @@ class ReceivedMessage:
|
|
|
50
50
|
key: Optional[bytes] = None
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
class PoisonRoute(str):
|
|
54
|
+
"""The ``route_urn`` a poison record is delivered under (the reserved ``NON_UTF8_ROUTE`` / ``POISON_ROUTE`` of a transport that dead-letters undecodable records, today Kafka). It compares and hashes exactly like the
|
|
55
|
+
plain route string, so the runtime and ``redrive`` keep recognising the reserved routes, but it
|
|
56
|
+
also carries what is needed to build the wrapped DLQ body when the record is dead-lettered:
|
|
57
|
+
the consumed queue, the original queue, the ``bq-attempts`` header value and the failure cause.
|
|
58
|
+
|
|
59
|
+
The wrap is applied by :meth:`KafkaTransport.publish_settle`, and only when the record is written
|
|
60
|
+
to a queue other than the one it was consumed from. That is exactly the runtime's dead-letter
|
|
61
|
+
write; ``redrive`` restores a poison record to the DLQ it popped it from, which therefore stays
|
|
62
|
+
byte for byte, whatever that DLQ is named (``dead_letter_queue`` / ``dead_letter_suffix``)."""
|
|
63
|
+
|
|
64
|
+
queue: str
|
|
65
|
+
original_queue: str
|
|
66
|
+
attempts: int
|
|
67
|
+
cause: str
|
|
68
|
+
|
|
69
|
+
def __new__(
|
|
70
|
+
cls, route: str, queue: str, original_queue: str, attempts: int, cause: str
|
|
71
|
+
) -> "PoisonRoute":
|
|
72
|
+
obj = super().__new__(cls, route)
|
|
73
|
+
obj.queue = queue
|
|
74
|
+
obj.original_queue = original_queue
|
|
75
|
+
obj.attempts = attempts
|
|
76
|
+
obj.cause = cause
|
|
77
|
+
return obj
|
|
78
|
+
|
|
79
|
+
|
|
53
80
|
class Transport(ABC):
|
|
54
81
|
"""Minimal broker contract: publish a raw body, pop one, acknowledge it."""
|
|
55
82
|
|