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.
Files changed (101) hide show
  1. {babelqueue-1.18.0 → babelqueue-1.19.0}/CHANGELOG.md +46 -0
  2. {babelqueue-1.18.0 → babelqueue-1.19.0}/PKG-INFO +1 -1
  3. {babelqueue-1.18.0 → babelqueue-1.19.0}/pyproject.toml +1 -1
  4. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/__init__.py +1 -1
  5. babelqueue-1.19.0/src/babelqueue/_envelope_shape.py +153 -0
  6. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/app.py +9 -0
  7. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/kafka_transport.py +176 -53
  8. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/redrive.py +6 -2
  9. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/transport.py +27 -0
  10. babelqueue-1.19.0/tests/conformance/CONFORMANCE_VERSION +1 -0
  11. babelqueue-1.19.0/tests/conformance/fixtures/duplicate-keys-last-wins.json +19 -0
  12. babelqueue-1.19.0/tests/conformance/fixtures/invalid-wrong-case-job.json +15 -0
  13. babelqueue-1.19.0/tests/conformance/fixtures/wrong-case-keys-roundtrip.json +20 -0
  14. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/manifest.json +33 -0
  15. babelqueue-1.19.0/tests/poison-trigger-set.json +392 -0
  16. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_kafka_transport.py +428 -43
  17. babelqueue-1.18.0/tests/conformance/CONFORMANCE_VERSION +0 -1
  18. {babelqueue-1.18.0 → babelqueue-1.19.0}/.github/FUNDING.yml +0 -0
  19. {babelqueue-1.18.0 → babelqueue-1.19.0}/.github/dependabot.yml +0 -0
  20. {babelqueue-1.18.0 → babelqueue-1.19.0}/.github/workflows/ci.yml +0 -0
  21. {babelqueue-1.18.0 → babelqueue-1.19.0}/.github/workflows/release.yml +0 -0
  22. {babelqueue-1.18.0 → babelqueue-1.19.0}/.gitignore +0 -0
  23. {babelqueue-1.18.0 → babelqueue-1.19.0}/LICENSE +0 -0
  24. {babelqueue-1.18.0 → babelqueue-1.19.0}/README.md +0 -0
  25. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/artemis_transport.py +0 -0
  26. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/asb_transport.py +0 -0
  27. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/celery.py +0 -0
  28. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/codec.py +0 -0
  29. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/contracts.py +0 -0
  30. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/dead_letter.py +0 -0
  31. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/django/__init__.py +0 -0
  32. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/django/apps.py +0 -0
  33. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/django/management/__init__.py +0 -0
  34. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  35. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  36. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/exceptions.py +0 -0
  37. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/gdpr.py +0 -0
  38. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/headers.py +0 -0
  39. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/idempotency.py +0 -0
  40. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/otel.py +0 -0
  41. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/outbox.py +0 -0
  42. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/pika_transport.py +0 -0
  43. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/pulsar_transport.py +0 -0
  44. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/py.typed +0 -0
  45. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/redis_transport.py +0 -0
  46. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/replay.py +0 -0
  47. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/routing.py +0 -0
  48. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/schema.py +0 -0
  49. {babelqueue-1.18.0 → babelqueue-1.19.0}/src/babelqueue/sqs_transport.py +0 -0
  50. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
  51. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  52. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
  53. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
  54. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
  55. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
  56. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
  57. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
  58. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  59. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  60. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/order-created.json +0 -0
  61. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  62. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
  63. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
  64. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
  65. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  66. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  67. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/fakes/confluent_kafka/__init__.py +0 -0
  68. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/fakes/confluent_kafka/admin.py +0 -0
  69. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/fixtures/dead-lettered.json +0 -0
  70. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/fixtures/order-created.json +0 -0
  71. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_app.py +0 -0
  72. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_artemis_conformance.py +0 -0
  73. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_artemis_transport.py +0 -0
  74. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_asb_conformance.py +0 -0
  75. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_asb_transport.py +0 -0
  76. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_behaviour_conformance.py +0 -0
  77. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_celery.py +0 -0
  78. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_codec.py +0 -0
  79. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_conformance.py +0 -0
  80. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_dead_letter.py +0 -0
  81. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_django.py +0 -0
  82. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_gdpr.py +0 -0
  83. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_headers.py +0 -0
  84. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_idempotency.py +0 -0
  85. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_kafka_conformance.py +0 -0
  86. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_kafka_integration.py +0 -0
  87. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_kafka_integration_logic.py +0 -0
  88. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_otel.py +0 -0
  89. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_outbox.py +0 -0
  90. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_overhead.py +0 -0
  91. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_pika_transport.py +0 -0
  92. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_pulsar_conformance.py +0 -0
  93. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_pulsar_transport.py +0 -0
  94. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_redis_transport.py +0 -0
  95. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_redrive.py +0 -0
  96. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_replay.py +0 -0
  97. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_schema.py +0 -0
  98. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_schema_conformance.py +0 -0
  99. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_shutdown.py +0 -0
  100. {babelqueue-1.18.0 → babelqueue-1.19.0}/tests/test_sqs_conformance.py +0 -0
  101. {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.18.0
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.18.0"
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"
@@ -41,7 +41,7 @@ from .transport import (
41
41
  Transport,
42
42
  )
43
43
 
44
- __version__ = "1.18.0"
44
+ __version__ = "1.19.0"
45
45
 
46
46
  __all__ = [
47
47
  "BabelQueue",
@@ -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 is handed to the runtime under a
23
- reserved route so its original bytes are dead-lettered raw (written back with no projected headers;
24
- with the DLQ off, or ``UnknownUrnStrategy.DELETE``, it is dropped and committed). The runtime logs
25
- it as a version-gated message because it reuses that settle path; the route in the log names it.
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
- NON_NUMERIC_ATTEMPTS_ROUTE,
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. Raises ``ValueError`` for every
83
- value that read would choke on — null, ``""``, a list/dict, ``"three"``, ``inf``/``nan`` — and
84
- for a bool, which is no counter at all although ``int()`` happens to accept it."""
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") or {}
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 _has_non_numeric_attempts(body: str) -> bool:
269
- """Whether the body is an envelope whose ``attempts`` the runtime's retry path could
270
- not read (see ``_runtime_attempts``), so such a record could never be retried. An absent
271
- ``attempts`` is a plain 0, not a poison."""
272
- env = parse_envelope(body)
273
- if not env or "attempts" not in env:
274
- return False
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
- _runtime_attempts(env)
277
- except ValueError:
278
- return True
279
- return False
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
- return {"value": body.encode("utf-8", "surrogateescape"), "headers": []}
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
- created_at = (parse_envelope(body).get("meta") or {}).get("created_at")
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
- kwargs["timestamp"] = int(created_at)
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
- if self._has_non_numeric_attempts(payload):
442
- # A foreign producer wrote a non-numeric ``attempts``. The runtime could not retry it
443
- # (it needs an integer counter) and a failing handler would wedge the partition, so it
444
- # is handed over under the reserved route like a non-UTF-8 record: dead-lettered raw,
445
- # never given to a handler, and restored (not replayed) by ``redrive``.
446
- _logger.warning(
447
- "Kafka record on %r carries a non-numeric attempts; dead-lettering it raw.", queue
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=NON_NUMERIC_ATTEMPTS_ROUTE,
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. A decode error above
461
- # escapes before this point and must not leave the record pending: the next ``pop`` would
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)) for message in popped
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 non-numeric-``attempts`` is not redrivable either: a
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