babelqueue 1.9.0__tar.gz → 1.10.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 (71) hide show
  1. {babelqueue-1.9.0 → babelqueue-1.10.0}/PKG-INFO +1 -1
  2. {babelqueue-1.9.0 → babelqueue-1.10.0}/pyproject.toml +1 -1
  3. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/__init__.py +8 -2
  4. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/app.py +14 -12
  5. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/redrive.py +25 -8
  6. babelqueue-1.10.0/src/babelqueue/replay.py +74 -0
  7. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/transport.py +28 -3
  8. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_redrive.py +1 -0
  9. babelqueue-1.10.0/tests/test_replay.py +124 -0
  10. {babelqueue-1.9.0 → babelqueue-1.10.0}/.github/FUNDING.yml +0 -0
  11. {babelqueue-1.9.0 → babelqueue-1.10.0}/.github/workflows/ci.yml +0 -0
  12. {babelqueue-1.9.0 → babelqueue-1.10.0}/.github/workflows/release.yml +0 -0
  13. {babelqueue-1.9.0 → babelqueue-1.10.0}/.gitignore +0 -0
  14. {babelqueue-1.9.0 → babelqueue-1.10.0}/CHANGELOG.md +0 -0
  15. {babelqueue-1.9.0 → babelqueue-1.10.0}/LICENSE +0 -0
  16. {babelqueue-1.9.0 → babelqueue-1.10.0}/README.md +0 -0
  17. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/artemis_transport.py +0 -0
  18. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/asb_transport.py +0 -0
  19. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/celery.py +0 -0
  20. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/codec.py +0 -0
  21. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/contracts.py +0 -0
  22. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/dead_letter.py +0 -0
  23. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/django/__init__.py +0 -0
  24. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/django/apps.py +0 -0
  25. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/django/management/__init__.py +0 -0
  26. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  27. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  28. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/exceptions.py +0 -0
  29. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/idempotency.py +0 -0
  30. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/kafka_transport.py +0 -0
  31. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/otel.py +0 -0
  32. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/pika_transport.py +0 -0
  33. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/pulsar_transport.py +0 -0
  34. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/py.typed +0 -0
  35. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/redis_transport.py +0 -0
  36. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/routing.py +0 -0
  37. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/schema.py +0 -0
  38. {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/sqs_transport.py +0 -0
  39. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  40. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  41. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  42. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/conformance/fixtures/order-created.json +0 -0
  43. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  44. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  45. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/conformance/manifest.json +0 -0
  46. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  47. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/fixtures/dead-lettered.json +0 -0
  48. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/fixtures/order-created.json +0 -0
  49. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_app.py +0 -0
  50. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_artemis_conformance.py +0 -0
  51. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_artemis_transport.py +0 -0
  52. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_asb_conformance.py +0 -0
  53. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_asb_transport.py +0 -0
  54. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_celery.py +0 -0
  55. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_codec.py +0 -0
  56. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_conformance.py +0 -0
  57. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_dead_letter.py +0 -0
  58. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_django.py +0 -0
  59. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_idempotency.py +0 -0
  60. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_kafka_conformance.py +0 -0
  61. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_kafka_transport.py +0 -0
  62. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_otel.py +0 -0
  63. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_overhead.py +0 -0
  64. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_pika_transport.py +0 -0
  65. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_pulsar_conformance.py +0 -0
  66. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_pulsar_transport.py +0 -0
  67. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_redis_transport.py +0 -0
  68. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_schema.py +0 -0
  69. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_schema_conformance.py +0 -0
  70. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_sqs_conformance.py +0 -0
  71. {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_sqs_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: babelqueue
3
- Version: 1.9.0
3
+ Version: 1.10.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.9.0"
7
+ version = "1.10.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"
@@ -11,14 +11,15 @@ and dead-letter helpers. Framework adapters (Celery, Django, ...) build on this.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- from . import dead_letter, idempotency, redrive
14
+ from . import dead_letter, idempotency, redrive, replay
15
15
  from .app import BabelQueue
16
16
  from .codec import SCHEMA_VERSION, SOURCE_LANG, EnvelopeCodec
17
17
  from .contracts import HasTraceId, PolyglotMessage
18
18
  from .idempotency import IdempotencyStore, InMemoryStore
19
19
  from .exceptions import BabelQueueError, UnknownUrnError
20
+ from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
20
21
  from .routing import UnknownUrnStrategy
21
- from .transport import InMemoryTransport, ReceivedMessage, Transport
22
+ from .transport import HeaderPublisher, InMemoryTransport, ReceivedMessage, Transport
22
23
 
23
24
  __version__ = "1.6.0"
24
25
 
@@ -33,11 +34,16 @@ __all__ = [
33
34
  "Transport",
34
35
  "InMemoryTransport",
35
36
  "ReceivedMessage",
37
+ "HeaderPublisher",
36
38
  "BabelQueueError",
37
39
  "UnknownUrnError",
38
40
  "dead_letter",
39
41
  "idempotency",
40
42
  "redrive",
43
+ "replay",
44
+ "is_replay",
45
+ "bypass_external_effects",
46
+ "HEADER_REPLAY_BYPASS",
41
47
  "IdempotencyStore",
42
48
  "InMemoryStore",
43
49
  "__version__",
@@ -25,6 +25,7 @@ from typing import Any, Callable, Dict, Mapping, Optional
25
25
  from . import dead_letter
26
26
  from .codec import EnvelopeCodec
27
27
  from .exceptions import UnknownUrnError
28
+ from .replay import HEADER_REPLAY_BYPASS, _replay_scope
28
29
  from .routing import UnknownUrnStrategy
29
30
  from .transport import ReceivedMessage, Transport, make_transport
30
31
 
@@ -116,18 +117,19 @@ class BabelQueue:
116
117
 
117
118
  def dispatch(self, received: ReceivedMessage) -> None:
118
119
  """Route one reserved message to its handler and acknowledge it."""
119
- envelope = EnvelopeCodec.decode(received.body)
120
- urn = str(envelope.get("job") or envelope.get("urn") or "")
121
- handler = self._handlers.get(urn) if urn else None
122
-
123
- try:
124
- if handler is None:
125
- self._route_unknown(urn, received, envelope)
126
- return
127
- self._invoke(handler, envelope)
128
- self.transport.ack(received)
129
- except Exception as exc: # noqa: BLE001 - one bad message must not kill the loop
130
- self._retry_or_dead_letter(received, envelope, exc)
120
+ with _replay_scope(bool(received.headers.get(HEADER_REPLAY_BYPASS))):
121
+ envelope = EnvelopeCodec.decode(received.body)
122
+ urn = str(envelope.get("job") or envelope.get("urn") or "")
123
+ handler = self._handlers.get(urn) if urn else None
124
+
125
+ try:
126
+ if handler is None:
127
+ self._route_unknown(urn, received, envelope)
128
+ return
129
+ self._invoke(handler, envelope)
130
+ self.transport.ack(received)
131
+ except Exception as exc: # noqa: BLE001 - one bad message must not kill the loop
132
+ self._retry_or_dead_letter(received, envelope, exc)
131
133
 
132
134
  # -- Internals ----------------------------------------------------------
133
135
 
@@ -20,12 +20,13 @@ dry-run, or undecodable) are never re-encountered in the same run; a DLQ message
20
20
  acknowledged only after a successful re-publish, and an undecodable body is restored, not
21
21
  dropped.
22
22
 
23
- Replay safety today is sandbox routing (``to_queue``) + ``dry_run``. The **Replay-Bypass**
24
- guard — a ``bq-replay-bypass`` transport header surfaced to handlers so a replay can skip
25
- external side-effects (don't re-charge, don't re-email) — is a documented phase two: like the
26
- OpenTelemetry ``traceparent`` follow-up, it carries out-of-band metadata as a transport header
27
- and so touches the runtime + every transport binding. Until then, sandbox routing is the
28
- safe-replay answer.
23
+ Replay safety is sandbox routing (``to_queue``) + ``dry_run``, plus the **Replay-Bypass** guard
24
+ (``bypass=True``): it stamps a ``bq-replay-bypass`` transport header that the runtime surfaces to
25
+ handlers via :func:`babelqueue.is_replay` / :func:`babelqueue.bypass_external_effects`, so a
26
+ replay can skip external side-effects that already fired (don't re-charge, don't re-email) — see
27
+ :mod:`babelqueue.replay` (ADR-0027). The header rides out of band, so the envelope stays frozen;
28
+ it propagates over a real broker only once that broker's transport implements the optional
29
+ :class:`~babelqueue.transport.HeaderPublisher` capability (the in-memory transport does today).
29
30
  """
30
31
 
31
32
  from __future__ import annotations
@@ -34,7 +35,8 @@ from dataclasses import dataclass, field
34
35
  from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
35
36
 
36
37
  from .codec import EnvelopeCodec
37
- from .transport import ReceivedMessage, Transport
38
+ from .replay import HEADER_REPLAY_BYPASS
39
+ from .transport import HeaderPublisher, ReceivedMessage, Transport
38
40
 
39
41
  Envelope = Mapping[str, Any]
40
42
  Select = Callable[[Envelope], bool]
@@ -51,6 +53,7 @@ class RedriveItem:
51
53
  from_queue: str
52
54
  to: str # target queue (the plan, even on a dry run; "" when skipped/undecodable)
53
55
  redriven: bool # True only when actually re-published to ``to``
56
+ bypassed: bool = False # True when the bq-replay-bypass header was stamped on the message
54
57
 
55
58
 
56
59
  @dataclass
@@ -70,6 +73,7 @@ def redrive(
70
73
  max: int = 0,
71
74
  dry_run: bool = False,
72
75
  select: Optional[Select] = None,
76
+ bypass: bool = False,
73
77
  timeout: float = 1.0,
74
78
  ) -> RedriveResult:
75
79
  """Move dead-lettered messages off ``dlq`` and replay them; see the module docstring."""
@@ -125,7 +129,9 @@ def redrive(
125
129
  reset.pop("dead_letter", None)
126
130
  reset["attempts"] = 0
127
131
  try:
128
- transport.publish(target, EnvelopeCodec.encode(reset))
132
+ item.bypassed = _publish_redriven(
133
+ transport, target, EnvelopeCodec.encode(reset), bypass
134
+ )
129
135
  except Exception:
130
136
  transport.publish(dlq, message.body) # restore on a publish failure, then surface
131
137
  transport.ack(message)
@@ -150,6 +156,17 @@ def _decoded(body: str) -> Optional[Dict[str, Any]]:
150
156
  return envelope
151
157
 
152
158
 
159
+ def _publish_redriven(transport: Transport, queue: str, body: str, bypass: bool) -> bool:
160
+ """Re-publish a reset message to ``queue``. When ``bypass`` is set and the transport is a
161
+ :class:`~babelqueue.transport.HeaderPublisher`, stamp the ``bq-replay-bypass`` header and
162
+ return True; otherwise publish plainly and return False."""
163
+ if bypass and isinstance(transport, HeaderPublisher):
164
+ transport.publish_with_headers(queue, body, {HEADER_REPLAY_BYPASS: "1"})
165
+ return True
166
+ transport.publish(queue, body)
167
+ return False
168
+
169
+
153
170
  def _source_queue_of(envelope: Envelope) -> str:
154
171
  """Default redrive target: ``dead_letter.original_queue``, falling back to ``meta.queue``."""
155
172
  dead_letter = envelope.get("dead_letter")
@@ -0,0 +1,74 @@
1
+ """Optional Replay-Bypass guard (ADR-0027): skip external side-effects on a deliberate replay.
2
+
3
+ The Python mirror of the Go ``replay.go``. It closes the loop left open by
4
+ :mod:`babelqueue.redrive`: a deliberate replay re-runs the handler, and its external
5
+ side-effects re-fire (a second charge, a duplicate email). :mod:`babelqueue.idempotency` stops
6
+ an *accidental* duplicate; this stops the *intended* reprocess from re-firing effects that
7
+ already happened.
8
+
9
+ ``Redrive`` (with ``bypass=True``) stamps a ``bq-replay-bypass`` transport header on a redriven
10
+ message; the runtime surfaces it to the handler via a :class:`contextvars.ContextVar`. A handler
11
+ wraps its external, non-idempotent side in :func:`bypass_external_effects` so a replay re-runs the
12
+ idempotent core but skips effects that already fired::
13
+
14
+ from babelqueue import is_replay, bypass_external_effects
15
+
16
+ @app.handler("urn:babel:orders:created")
17
+ def on_order_created(data, meta):
18
+ save_order(data) # idempotent core — always runs
19
+ bypass_external_effects(lambda: send_email(data)) # external effect — skipped on replay
20
+
21
+ The marker rides **out of band** as a transport header, so the frozen envelope is untouched
22
+ (GR-1). It propagates over a real broker only once that broker's concrete transport implements
23
+ the optional :class:`~babelqueue.transport.HeaderPublisher` capability — a follow-up, like the
24
+ broker bindings; the in-memory transport supports it today, so the path is end-to-end testable.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import contextlib
30
+ import contextvars
31
+ from typing import Callable, Iterator, Optional, TypeVar
32
+
33
+ #: The out-of-band transport header :func:`~babelqueue.redrive.redrive` stamps on a replayed
34
+ #: message (with ``bypass=True``) and that the runtime surfaces as :func:`is_replay`.
35
+ HEADER_REPLAY_BYPASS = "bq-replay-bypass"
36
+
37
+ _replay_var: contextvars.ContextVar[bool] = contextvars.ContextVar(
38
+ "babelqueue_replay", default=False
39
+ )
40
+
41
+ T = TypeVar("T")
42
+
43
+
44
+ def is_replay() -> bool:
45
+ """Whether the message currently being handled was redriven with the replay-bypass marker.
46
+
47
+ True means this is a deliberate replay, so external side-effects that already happened should
48
+ be skipped. Reads the flag the runtime set on the context from the
49
+ :data:`HEADER_REPLAY_BYPASS` transport header.
50
+ """
51
+ return _replay_var.get()
52
+
53
+
54
+ def bypass_external_effects(fn: Callable[[], T]) -> Optional[T]:
55
+ """Run ``fn`` unless the current message is a replay (see :func:`is_replay`), in which case
56
+ skip it and return ``None``.
57
+
58
+ Wrap the external, non-idempotent side of a handler — sending an email, charging a card,
59
+ calling a third party — so a replay re-runs the idempotent core but does not re-fire effects
60
+ that already happened.
61
+ """
62
+ if is_replay():
63
+ return None
64
+ return fn()
65
+
66
+
67
+ @contextlib.contextmanager
68
+ def _replay_scope(active: bool) -> Iterator[None]:
69
+ """Internal: mark the current context as a replay (or not) for the span of one dispatch."""
70
+ token = _replay_var.set(active)
71
+ try:
72
+ yield
73
+ finally:
74
+ _replay_var.reset(token)
@@ -8,8 +8,8 @@ from __future__ import annotations
8
8
 
9
9
  from abc import ABC, abstractmethod
10
10
  from collections import defaultdict, deque
11
- from dataclasses import dataclass
12
- from typing import Any, Deque, Dict, Optional
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Deque, Dict, Optional, Protocol, runtime_checkable
13
13
 
14
14
  from .exceptions import BabelQueueError
15
15
 
@@ -21,6 +21,9 @@ class ReceivedMessage:
21
21
  body: str
22
22
  queue: str
23
23
  handle: Any = None
24
+ #: Out-of-band transport headers a :class:`HeaderPublisher` carried with the message
25
+ #: (e.g. the ``bq-replay-bypass`` marker). Empty for transports that don't surface them.
26
+ headers: Dict[str, str] = field(default_factory=dict)
24
27
 
25
28
 
26
29
  class Transport(ABC):
@@ -42,20 +45,42 @@ class Transport(ABC):
42
45
  """Release any resources (override if needed)."""
43
46
 
44
47
 
48
+ @runtime_checkable
49
+ class HeaderPublisher(Protocol):
50
+ """Optional :class:`Transport` capability: publish a body together with out-of-band
51
+ transport headers (e.g. the replay-bypass marker), for brokers that carry per-message
52
+ metadata. A transport that does not implement it simply does not propagate headers —
53
+ callers fall back to plain :meth:`Transport.publish` (ADR-0027)."""
54
+
55
+ def publish_with_headers(self, queue: str, body: str, headers: Dict[str, str]) -> None:
56
+ """Append ``body`` to ``queue`` along with out-of-band ``headers``."""
57
+ ...
58
+
59
+
45
60
  class InMemoryTransport(Transport):
46
61
  """In-process transport for tests and broker-free local runs (``memory://``)."""
47
62
 
48
63
  def __init__(self) -> None:
64
+ # Bodies and their out-of-band headers are kept in lockstep parallel deques, so the
65
+ # body storage layout stays a plain Deque[str].
49
66
  self._queues: Dict[str, Deque[str]] = defaultdict(deque)
67
+ self._headers: Dict[str, Deque[Dict[str, str]]] = defaultdict(deque)
50
68
 
51
69
  def publish(self, queue: str, body: str) -> None:
52
70
  self._queues[queue].append(body)
71
+ self._headers[queue].append({})
72
+
73
+ def publish_with_headers(self, queue: str, body: str, headers: Dict[str, str]) -> None:
74
+ self._queues[queue].append(body)
75
+ self._headers[queue].append(dict(headers))
53
76
 
54
77
  def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
55
78
  dq = self._queues.get(queue)
56
79
  if not dq:
57
80
  return None
58
- return ReceivedMessage(body=dq.popleft(), queue=queue)
81
+ return ReceivedMessage(
82
+ body=dq.popleft(), queue=queue, headers=self._headers[queue].popleft()
83
+ )
59
84
 
60
85
  def ack(self, message: ReceivedMessage) -> None:
61
86
  # Already removed on pop; nothing to do.
@@ -33,6 +33,7 @@ def test_redrive_to_source():
33
33
  result = redrive(t, "orders.dlq")
34
34
 
35
35
  assert result.redriven == 1 and result.skipped == 0
36
+ assert result.items[0].bypassed is False # bypass is off by default
36
37
  got = _drain(t, "orders")
37
38
  assert len(got) == 1
38
39
  assert "dead_letter" not in got[0]
@@ -0,0 +1,124 @@
1
+ """Tests for the optional Replay-Bypass guard (ADR-0027)."""
2
+
3
+ from babelqueue import (
4
+ BabelQueue,
5
+ bypass_external_effects,
6
+ dead_letter,
7
+ is_replay,
8
+ )
9
+ from babelqueue.codec import EnvelopeCodec
10
+ from babelqueue.redrive import redrive
11
+ from babelqueue.replay import HEADER_REPLAY_BYPASS, _replay_scope
12
+ from babelqueue.transport import InMemoryTransport, Transport
13
+
14
+
15
+ def _dead_letter(transport, dlq, urn, original_queue, data=None):
16
+ env = EnvelopeCodec.make(urn, data or {}, queue=original_queue)
17
+ annotated = dead_letter.annotate(env, "failed", original_queue, 3, error="boom")
18
+ transport.publish(dlq, EnvelopeCodec.encode(annotated))
19
+ return annotated
20
+
21
+
22
+ def test_is_replay_default_false():
23
+ assert is_replay() is False
24
+
25
+
26
+ def test_bypass_runs_when_not_replay():
27
+ ran = []
28
+
29
+ def effect():
30
+ ran.append("x")
31
+ return "did-it"
32
+
33
+ assert bypass_external_effects(effect) == "did-it"
34
+ assert ran == ["x"]
35
+
36
+
37
+ def test_bypass_skips_on_replay_and_scope_resets():
38
+ ran = []
39
+
40
+ def effect():
41
+ ran.append("x")
42
+ raise AssertionError("must not run on a replay")
43
+
44
+ with _replay_scope(True):
45
+ assert is_replay() is True
46
+ assert bypass_external_effects(effect) is None
47
+ assert ran == []
48
+ assert is_replay() is False # the scope reset the contextvar
49
+
50
+
51
+ def test_in_memory_transport_carries_headers():
52
+ t = InMemoryTransport()
53
+ t.publish_with_headers("q", "body", {HEADER_REPLAY_BYPASS: "1"})
54
+ msg = t.pop("q", 0)
55
+ assert msg is not None and msg.headers.get(HEADER_REPLAY_BYPASS) == "1"
56
+ # a plain publish carries no headers
57
+ t.publish("q", "plain")
58
+ msg2 = t.pop("q", 0)
59
+ assert msg2 is not None and msg2.headers == {}
60
+
61
+
62
+ def test_redrive_bypass_stamps_header_and_consume_skips_effects():
63
+ t = InMemoryTransport()
64
+ _dead_letter(t, "orders.dlq", "urn:babel:orders:created", "orders", {"order_id": 1})
65
+
66
+ result = redrive(t, "orders.dlq", bypass=True)
67
+ assert result.redriven == 1
68
+ assert result.items[0].bypassed is True
69
+
70
+ # the redriven message on the source queue carries the bypass header
71
+ msg = t.pop("orders", 0)
72
+ assert msg is not None and msg.headers.get(HEADER_REPLAY_BYPASS) == "1"
73
+
74
+ # consume it: the handler sees is_replay and BypassExternalEffects skips the side-effect
75
+ emailed = []
76
+ app = BabelQueue(transport=t)
77
+
78
+ @app.handler("urn:babel:orders:created")
79
+ def on_created(data, meta):
80
+ assert is_replay() is True
81
+ bypass_external_effects(lambda: emailed.append(data))
82
+
83
+ app.dispatch(msg)
84
+ assert emailed == [] # the external side-effect was skipped on the bypassed replay
85
+
86
+
87
+ def test_normal_delivery_is_not_a_replay():
88
+ t = InMemoryTransport()
89
+ app = BabelQueue(transport=t)
90
+ fired = []
91
+
92
+ @app.handler("urn:babel:orders:created")
93
+ def on_created(data, meta):
94
+ assert is_replay() is False
95
+ bypass_external_effects(lambda: fired.append(data))
96
+
97
+ app.publish("urn:babel:orders:created", {"order_id": 9})
98
+ app.consume(max_messages=1, timeout=0)
99
+ assert fired == [{"order_id": 9}] # effect runs on a normal (non-replay) delivery
100
+
101
+
102
+ class _PlainTransport(Transport):
103
+ """A Transport that is deliberately NOT a HeaderPublisher (no publish_with_headers)."""
104
+
105
+ def __init__(self):
106
+ self._inner = InMemoryTransport()
107
+
108
+ def publish(self, queue, body):
109
+ self._inner.publish(queue, body)
110
+
111
+ def pop(self, queue, timeout=1.0):
112
+ return self._inner.pop(queue, timeout)
113
+
114
+ def ack(self, message):
115
+ self._inner.ack(message)
116
+
117
+
118
+ def test_redrive_bypass_without_header_support_falls_back():
119
+ t = _PlainTransport()
120
+ _dead_letter(t, "orders.dlq", "urn:babel:orders:created", "orders")
121
+
122
+ result = redrive(t, "orders.dlq", bypass=True)
123
+ assert result.redriven == 1
124
+ assert result.items[0].bypassed is False # no-op without a HeaderPublisher
File without changes
File without changes
File without changes
File without changes