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.
Files changed (104) hide show
  1. {babelqueue-1.17.0 → babelqueue-1.19.0}/CHANGELOG.md +75 -0
  2. {babelqueue-1.17.0 → babelqueue-1.19.0}/PKG-INFO +1 -1
  3. {babelqueue-1.17.0 → babelqueue-1.19.0}/pyproject.toml +1 -1
  4. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/__init__.py +3 -1
  5. babelqueue-1.19.0/src/babelqueue/_envelope_shape.py +153 -0
  6. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/app.py +9 -0
  7. babelqueue-1.19.0/src/babelqueue/kafka_transport.py +721 -0
  8. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/redrive.py +43 -12
  9. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/transport.py +52 -1
  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.17.0 → babelqueue-1.19.0}/tests/conformance/manifest.json +33 -0
  15. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/fakes/confluent_kafka/__init__.py +12 -4
  16. babelqueue-1.19.0/tests/poison-trigger-set.json +392 -0
  17. babelqueue-1.19.0/tests/test_kafka_integration.py +311 -0
  18. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_kafka_integration_logic.py +1 -1
  19. babelqueue-1.19.0/tests/test_kafka_transport.py +1793 -0
  20. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_redrive.py +40 -0
  21. babelqueue-1.17.0/src/babelqueue/kafka_transport.py +0 -429
  22. babelqueue-1.17.0/tests/conformance/CONFORMANCE_VERSION +0 -1
  23. babelqueue-1.17.0/tests/test_kafka_integration.py +0 -169
  24. babelqueue-1.17.0/tests/test_kafka_transport.py +0 -985
  25. {babelqueue-1.17.0 → babelqueue-1.19.0}/.github/FUNDING.yml +0 -0
  26. {babelqueue-1.17.0 → babelqueue-1.19.0}/.github/dependabot.yml +0 -0
  27. {babelqueue-1.17.0 → babelqueue-1.19.0}/.github/workflows/ci.yml +0 -0
  28. {babelqueue-1.17.0 → babelqueue-1.19.0}/.github/workflows/release.yml +0 -0
  29. {babelqueue-1.17.0 → babelqueue-1.19.0}/.gitignore +0 -0
  30. {babelqueue-1.17.0 → babelqueue-1.19.0}/LICENSE +0 -0
  31. {babelqueue-1.17.0 → babelqueue-1.19.0}/README.md +0 -0
  32. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/artemis_transport.py +0 -0
  33. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/asb_transport.py +0 -0
  34. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/celery.py +0 -0
  35. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/codec.py +0 -0
  36. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/contracts.py +0 -0
  37. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/dead_letter.py +0 -0
  38. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/django/__init__.py +0 -0
  39. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/django/apps.py +0 -0
  40. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/django/management/__init__.py +0 -0
  41. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  42. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  43. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/exceptions.py +0 -0
  44. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/gdpr.py +0 -0
  45. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/headers.py +0 -0
  46. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/idempotency.py +0 -0
  47. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/otel.py +0 -0
  48. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/outbox.py +0 -0
  49. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/pika_transport.py +0 -0
  50. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/pulsar_transport.py +0 -0
  51. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/py.typed +0 -0
  52. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/redis_transport.py +0 -0
  53. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/replay.py +0 -0
  54. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/routing.py +0 -0
  55. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/schema.py +0 -0
  56. {babelqueue-1.17.0 → babelqueue-1.19.0}/src/babelqueue/sqs_transport.py +0 -0
  57. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
  58. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  59. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
  60. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
  61. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
  62. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
  63. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
  64. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
  65. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  66. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  67. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/order-created.json +0 -0
  68. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  69. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
  70. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
  71. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
  72. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  73. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  74. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/fakes/confluent_kafka/admin.py +0 -0
  75. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/fixtures/dead-lettered.json +0 -0
  76. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/fixtures/order-created.json +0 -0
  77. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_app.py +0 -0
  78. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_artemis_conformance.py +0 -0
  79. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_artemis_transport.py +0 -0
  80. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_asb_conformance.py +0 -0
  81. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_asb_transport.py +0 -0
  82. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_behaviour_conformance.py +0 -0
  83. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_celery.py +0 -0
  84. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_codec.py +0 -0
  85. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_conformance.py +0 -0
  86. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_dead_letter.py +0 -0
  87. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_django.py +0 -0
  88. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_gdpr.py +0 -0
  89. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_headers.py +0 -0
  90. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_idempotency.py +0 -0
  91. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_kafka_conformance.py +0 -0
  92. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_otel.py +0 -0
  93. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_outbox.py +0 -0
  94. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_overhead.py +0 -0
  95. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_pika_transport.py +0 -0
  96. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_pulsar_conformance.py +0 -0
  97. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_pulsar_transport.py +0 -0
  98. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_redis_transport.py +0 -0
  99. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_replay.py +0 -0
  100. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_schema.py +0 -0
  101. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_schema_conformance.py +0 -0
  102. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_shutdown.py +0 -0
  103. {babelqueue-1.17.0 → babelqueue-1.19.0}/tests/test_sqs_conformance.py +0 -0
  104. {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.17.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.17.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"
@@ -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.17.0"
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