rabbitkit 0.14.0__tar.gz → 0.17.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 (107) hide show
  1. {rabbitkit-0.14.0/src/rabbitkit.egg-info → rabbitkit-0.17.0}/PKG-INFO +12 -7
  2. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/README.md +8 -3
  3. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/pyproject.toml +13 -4
  4. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/__init__.py +2 -0
  5. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/_version.py +1 -1
  6. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/async_/broker.py +2 -0
  7. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/commands/topology.py +21 -2
  8. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/concurrency.py +19 -3
  9. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/config.py +9 -0
  10. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/logging.py +120 -0
  11. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/pipeline.py +34 -0
  12. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/registry.py +8 -0
  13. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/route.py +36 -0
  14. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/router.py +2 -0
  15. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/sanitizer.py +24 -2
  16. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/settlement.py +461 -107
  17. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/highload/__init__.py +2 -0
  18. rabbitkit-0.17.0/src/rabbitkit/highload/async_acker.py +345 -0
  19. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/highload/batch.py +62 -5
  20. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/compression.py +40 -18
  21. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/deduplication.py +21 -5
  22. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/signing.py +114 -21
  23. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/sync/broker.py +2 -0
  24. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/testing/broker.py +2 -0
  25. {rabbitkit-0.14.0 → rabbitkit-0.17.0/src/rabbitkit.egg-info}/PKG-INFO +12 -7
  26. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit.egg-info/SOURCES.txt +1 -0
  27. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit.egg-info/requires.txt +3 -3
  28. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/LICENSE +0 -0
  29. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/setup.cfg +0 -0
  30. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/aio/__init__.py +0 -0
  31. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/async_/__init__.py +0 -0
  32. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/async_/batch.py +0 -0
  33. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/async_/connection.py +0 -0
  34. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/async_/pool.py +0 -0
  35. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/async_/transport.py +0 -0
  36. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/asyncapi/__init__.py +0 -0
  37. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/asyncapi/generator.py +0 -0
  38. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/asyncapi/schema.py +0 -0
  39. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/__init__.py +0 -0
  40. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/_utils.py +0 -0
  41. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/commands/__init__.py +0 -0
  42. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/commands/dlq.py +0 -0
  43. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/commands/health.py +0 -0
  44. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/commands/migrate.py +0 -0
  45. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/commands/routes.py +0 -0
  46. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/commands/run.py +0 -0
  47. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/commands/shell.py +0 -0
  48. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/__init__.py +0 -0
  49. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/app.py +0 -0
  50. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/bulk.py +0 -0
  51. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/bulk_runner.py +0 -0
  52. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/env_config.py +0 -0
  53. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/errors.py +0 -0
  54. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/message.py +0 -0
  55. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/path.py +0 -0
  56. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/profiles.py +0 -0
  57. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/protocols.py +0 -0
  58. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/retry_handoff.py +0 -0
  59. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/topology.py +0 -0
  60. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/topology_dispatch.py +0 -0
  61. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/core/types.py +0 -0
  62. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/dashboard/__init__.py +0 -0
  63. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/dashboard/app.py +0 -0
  64. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/di/__init__.py +0 -0
  65. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/di/context.py +0 -0
  66. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/di/depends.py +0 -0
  67. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/di/resolver.py +0 -0
  68. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/dlq.py +0 -0
  69. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/experimental/__init__.py +0 -0
  70. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/fastapi.py +0 -0
  71. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/health.py +0 -0
  72. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/highload/backpressure.py +0 -0
  73. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/locking.py +0 -0
  74. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/management.py +0 -0
  75. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/__init__.py +0 -0
  76. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/base.py +0 -0
  77. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/circuit_breaker.py +0 -0
  78. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/error_classifier.py +0 -0
  79. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/exception.py +0 -0
  80. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/metrics.py +0 -0
  81. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/otel.py +0 -0
  82. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/rate_limit.py +0 -0
  83. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/retry.py +0 -0
  84. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/timeout.py +0 -0
  85. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/py.typed +0 -0
  86. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/queue_metrics.py +0 -0
  87. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/results/__init__.py +0 -0
  88. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/results/backend.py +0 -0
  89. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/results/middleware.py +0 -0
  90. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/rpc.py +0 -0
  91. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/serialization/__init__.py +0 -0
  92. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/serialization/base.py +0 -0
  93. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/serialization/json.py +0 -0
  94. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/serialization/msgspec.py +0 -0
  95. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/serialization/pipeline.py +0 -0
  96. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/streams.py +0 -0
  97. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/sync/__init__.py +0 -0
  98. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/sync/batch.py +0 -0
  99. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/sync/connection.py +0 -0
  100. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/sync/pool.py +0 -0
  101. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/sync/transport.py +0 -0
  102. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/testing/__init__.py +0 -0
  103. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/testing/app.py +0 -0
  104. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit/testing/fixtures.py +0 -0
  105. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit.egg-info/dependency_links.txt +0 -0
  106. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit.egg-info/entry_points.txt +0 -0
  107. {rabbitkit-0.14.0 → rabbitkit-0.17.0}/src/rabbitkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rabbitkit
3
- Version: 0.14.0
3
+ Version: 0.17.0
4
4
  Summary: Production-grade RabbitMQ toolkit — sync/async, decorator routing, retry, compression, full configurability
5
5
  Author-email: Talaat Magdy <talaatmagdy75@gmail.com>
6
6
  License-Expression: MIT
@@ -33,7 +33,7 @@ Requires-Dist: rabbitkit[async,sync]; extra == "all-brokers"
33
33
  Provides-Extra: redis
34
34
  Requires-Dist: redis<9.0.0,>=5.0.0; extra == "redis"
35
35
  Provides-Extra: pydantic
36
- Requires-Dist: pydantic<3.0.0,>=2.0.0; extra == "pydantic"
36
+ Requires-Dist: pydantic<3.0.0,>=2.4.0; extra == "pydantic"
37
37
  Provides-Extra: msgspec
38
38
  Requires-Dist: msgspec<1.0.0,>=0.18.0; extra == "msgspec"
39
39
  Provides-Extra: fastapi
@@ -41,7 +41,7 @@ Requires-Dist: fastapi<1.0.0,>=0.111.0; extra == "fastapi"
41
41
  Provides-Extra: compression
42
42
  Requires-Dist: zstandard<1.0.0,>=0.22.0; extra == "compression"
43
43
  Provides-Extra: management
44
- Requires-Dist: aiohttp<4.0.0,>=3.9.0; extra == "management"
44
+ Requires-Dist: aiohttp<4.0.0,>=3.14.3; extra == "management"
45
45
  Provides-Extra: otel
46
46
  Requires-Dist: opentelemetry-api<2,>=1.20; extra == "otel"
47
47
  Provides-Extra: settings
@@ -49,7 +49,7 @@ Requires-Dist: pydantic-settings<3.0.0,>=2.0.0; extra == "settings"
49
49
  Provides-Extra: cli
50
50
  Requires-Dist: typer<1.0.0,>=0.12.0; extra == "cli"
51
51
  Provides-Extra: dashboard
52
- Requires-Dist: starlette<2.0.0,>=0.37.0; extra == "dashboard"
52
+ Requires-Dist: starlette<2.0.0,>=1.4.0; extra == "dashboard"
53
53
  Requires-Dist: uvicorn<1.0.0,>=0.29.0; extra == "dashboard"
54
54
  Provides-Extra: reload
55
55
  Requires-Dist: watchfiles<2.0.0,>=0.21.0; extra == "reload"
@@ -423,9 +423,14 @@ management_client=...)`) verify confirms/mandatory/persistence, a 256 KiB body
423
423
  cap, quorum queues and a quorum retry chain — and report anything they cannot
424
424
  verify as *unverified*, never green. Retry/DLQ triage headers are sanitized by
425
425
  default, and a failing delay-queue handoff backs off instead of hot-looping.
426
- `CoalescingAcker` is the only place a cumulative `basic.ack(multiple=True)`
427
- may originate: it knows every delivery on the channel and coalesces only
428
- through a completed prefix, so a still-running sibling is never acked early.
426
+ `CoalescingAcker` (and `AsyncCoalescingAcker`) is the only place a cumulative
427
+ `basic.ack(multiple=True)` may originate: it knows every delivery on the
428
+ channel and coalesces only through a completed prefix, so a still-running
429
+ sibling is never acked early. Settlement follows a **plan / emit / commit**
430
+ protocol — the ledger advances only for frames the broker actually accepted,
431
+ and emission stops at the first failure, because a cumulative ack that
432
+ follows a nack which never landed would acknowledge the very delivery meant
433
+ to be requeued.
429
434
  Every bulk path emits bounded-label metrics
430
435
  (`rabbitkit_bulk_publish_items_total{status,reason}`,
431
436
  `rabbitkit_settlement_items_total{action,status}`,
@@ -342,9 +342,14 @@ management_client=...)`) verify confirms/mandatory/persistence, a 256 KiB body
342
342
  cap, quorum queues and a quorum retry chain — and report anything they cannot
343
343
  verify as *unverified*, never green. Retry/DLQ triage headers are sanitized by
344
344
  default, and a failing delay-queue handoff backs off instead of hot-looping.
345
- `CoalescingAcker` is the only place a cumulative `basic.ack(multiple=True)`
346
- may originate: it knows every delivery on the channel and coalesces only
347
- through a completed prefix, so a still-running sibling is never acked early.
345
+ `CoalescingAcker` (and `AsyncCoalescingAcker`) is the only place a cumulative
346
+ `basic.ack(multiple=True)` may originate: it knows every delivery on the
347
+ channel and coalesces only through a completed prefix, so a still-running
348
+ sibling is never acked early. Settlement follows a **plan / emit / commit**
349
+ protocol — the ledger advances only for frames the broker actually accepted,
350
+ and emission stops at the first failure, because a cumulative ack that
351
+ follows a nack which never landed would acknowledge the very delivery meant
352
+ to be requeued.
348
353
  Every bulk path emits bounded-label metrics
349
354
  (`rabbitkit_bulk_publish_items_total{status,reason}`,
350
355
  `rabbitkit_settlement_items_total{action,status}`,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rabbitkit"
3
- version = "0.14.0"
3
+ version = "0.17.0"
4
4
  description = "Production-grade RabbitMQ toolkit — sync/async, decorator routing, retry, compression, full configurability"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -34,15 +34,24 @@ sync = ["pika>=1.3.0,<2.0.0"]
34
34
  async = ["aio-pika>=9.1.0,<10.0.0"]
35
35
  all-brokers = ["rabbitkit[sync,async]"]
36
36
  redis = ["redis>=5.0.0,<9.0.0"]
37
- pydantic = ["pydantic>=2.0.0,<3.0.0"]
37
+ # Floor is the first release with no open advisory (verified against OSV):
38
+ # 2.0.0-2.3.x carry GHSA-mr82-8j83-vxmv / PYSEC-2026-1812.
39
+ pydantic = ["pydantic>=2.4.0,<3.0.0"]
38
40
  msgspec = ["msgspec>=0.18.0,<1.0.0"]
39
41
  fastapi = ["fastapi>=0.111.0,<1.0.0"]
40
42
  compression = ["zstandard>=0.22.0,<1.0.0"]
41
- management = ["aiohttp>=3.9.0,<4.0.0"]
43
+ # 3.14.3 is the first aiohttp with a clean advisory set (verified against
44
+ # OSV: 3.14.0 -> 22, 3.14.1 -> 6, 3.14.2 -> 2, 3.14.3 -> 0). The old
45
+ # >=3.9.0 floor permitted a resolution with 76 known advisories, and CI
46
+ # could never see it because it always resolves the newest in range.
47
+ management = ["aiohttp>=3.14.3,<4.0.0"]
42
48
  otel = ["opentelemetry-api>=1.20,<2"]
43
49
  settings = ["pydantic-settings>=2.0.0,<3.0.0"]
44
50
  cli = ["typer>=0.12.0,<1.0.0"]
45
- dashboard = ["starlette>=0.37.0,<2.0.0", "uvicorn>=0.29.0,<1.0.0"]
51
+ # starlette 1.4.0 is the first clean release (1.0 -> 10 advisories,
52
+ # 1.3 -> 2, 1.4 -> 0). The upper bound already allowed 1.x and CI has
53
+ # been testing 1.6, so this only removes resolutions nothing tested.
54
+ dashboard = ["starlette>=1.4.0,<2.0.0", "uvicorn>=0.29.0,<1.0.0"]
46
55
  reload = ["watchfiles>=0.21.0,<2.0.0"]
47
56
  all = ["rabbitkit[all-brokers,otel,redis,pydantic,msgspec,fastapi,compression,settings,cli,dashboard,reload,management]"]
48
57
  dev = [
@@ -115,6 +115,7 @@ from rabbitkit.health import (
115
115
  broker_readiness,
116
116
  broker_readiness_async,
117
117
  )
118
+ from rabbitkit.highload.async_acker import AsyncCoalescingAcker
118
119
  from rabbitkit.highload.backpressure import FlowController
119
120
  from rabbitkit.highload.batch import (
120
121
  BatchAcker,
@@ -163,6 +164,7 @@ __all__ = [
163
164
  "AsyncAPIGeneratorConfig",
164
165
  "AsyncBatchPublisher",
165
166
  "AsyncBroker",
167
+ "AsyncCoalescingAcker",
166
168
  "AsyncHealthWatcher",
167
169
  "AsyncWorkerPool",
168
170
  "BackpressureConfig",
@@ -1,3 +1,3 @@
1
1
  """rabbitkit version — single source of truth."""
2
2
 
3
- __version__ = "0.14.0"
3
+ __version__ = "0.17.0"
@@ -285,6 +285,7 @@ class AsyncBroker:
285
285
  prefetch_count: int | None = None,
286
286
  filter_fn: Callable[[RabbitMessage], bool] | None = None,
287
287
  reject_without_dlx: str | None = None,
288
+ reply_to_allow: tuple[str, ...] | None = None,
288
289
  ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
289
290
  """Register a subscriber handler."""
290
291
  return self._registry.subscriber(
@@ -301,6 +302,7 @@ class AsyncBroker:
301
302
  prefetch_count=prefetch_count,
302
303
  filter_fn=filter_fn,
303
304
  reject_without_dlx=reject_without_dlx,
305
+ reply_to_allow=reply_to_allow,
304
306
  )
305
307
 
306
308
  def publisher(
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import urllib.parse
6
7
  from typing import Any, cast
7
8
 
8
9
  import typer
@@ -15,6 +16,24 @@ topology_app = typer.Typer(help="Topology inspection and management commands.")
15
16
  topology_app.command("migrate")(migrate_command)
16
17
 
17
18
 
19
+ def _safe_url(management_url: str) -> str:
20
+ """The URL with any embedded credentials removed, for display.
21
+
22
+ ``--url`` accepts ``http://user:pass@host:15672`` (the form ``migrate``
23
+ documents), and these commands echo the URL back on failure. Printing it
24
+ verbatim puts the password into terminal scrollback and, in CI, into the
25
+ build log.
26
+ """
27
+ try:
28
+ parsed = urllib.parse.urlparse(management_url)
29
+ if not parsed.hostname:
30
+ return "<redacted url>"
31
+ netloc = parsed.hostname if parsed.port is None else f"{parsed.hostname}:{parsed.port}"
32
+ return f"{parsed.scheme}://{netloc}{parsed.path.rstrip('/')}"
33
+ except Exception:
34
+ return "<redacted url>"
35
+
36
+
18
37
  @topology_app.command("list")
19
38
  def topology_list(
20
39
  app_path: str = typer.Argument(..., help="Broker path, e.g. 'myapp.main:broker'"),
@@ -143,7 +162,7 @@ def topology_validate(
143
162
  try:
144
163
  live = _live_resources(management_url, vhost)
145
164
  except Exception as exc:
146
- typer.echo(f"ERROR: could not reach management API at {management_url}: {exc}", err=True)
165
+ typer.echo(f"ERROR: could not reach management API at {_safe_url(management_url)}: {exc}", err=True)
147
166
  raise typer.Exit(1) from None
148
167
 
149
168
  issues: list[str] = []
@@ -203,7 +222,7 @@ def topology_diff(
203
222
  try:
204
223
  live = _live_resources(management_url, vhost)
205
224
  except Exception as exc:
206
- typer.echo(f"ERROR: could not reach management API at {management_url}: {exc}", err=True)
225
+ typer.echo(f"ERROR: could not reach management API at {_safe_url(management_url)}: {exc}", err=True)
207
226
  raise typer.Exit(1) from None
208
227
 
209
228
  diff: dict[str, Any] = {
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  import asyncio
15
15
  import concurrent.futures
16
+ import contextvars
16
17
  import logging
17
18
  import queue
18
19
  import threading
@@ -112,7 +113,18 @@ class _DaemonWorkerPool:
112
113
  fut, fn, args, kwargs = item
113
114
  if fut.set_running_or_notify_cancel():
114
115
  try:
115
- fut.set_result(fn(*args, **kwargs))
116
+ # Run in a FRESH context per message.
117
+ #
118
+ # A pooled thread is long-lived and carries ONE
119
+ # contextvars.Context for its entire life, so calling the
120
+ # handler directly let message A's ContextVar writes leak
121
+ # into message B on the same thread. With DI's
122
+ # `set_local`, tenant B could read tenant A's scope --
123
+ # which contradicts the isolation the DI context class
124
+ # advertises. The async path is safe because each message
125
+ # is its own Task (and a Task already copies the context);
126
+ # this gives the sync pool the same guarantee.
127
+ fut.set_result(contextvars.copy_context().run(fn, *args, **kwargs))
116
128
  except BaseException as exc:
117
129
  fut.set_exception(exc)
118
130
  # loop back: re-mark idle at the top of the next iteration
@@ -272,8 +284,12 @@ class SyncWorkerPool:
272
284
  Otherwise, submits to the thread pool.
273
285
  """
274
286
  if self._executor is None:
275
- # Single worker mode -- run directly
276
- callback(message)
287
+ # Single worker mode -- run directly, but still in a FRESH
288
+ # context. This runs on the transport's owner thread, which lives
289
+ # for the whole process, so without the copy every message's
290
+ # ContextVar writes would accumulate on it and leak into the next
291
+ # message exactly as they did on the pooled path.
292
+ contextvars.copy_context().run(callback, message)
277
293
  return
278
294
 
279
295
  future = self._executor.submit(callback, message)
@@ -902,6 +902,15 @@ class DeduplicationConfig:
902
902
  store_results: bool = False
903
903
  max_result_bytes: int = 65536
904
904
  on_in_flight: str = "nack_requeue" # claim only: "nack_requeue" (retry-safe) | "ack_skip"
905
+ #: Seconds to wait before nack-requeueing a duplicate of an IN-FLIGHT
906
+ #: message. Without it the requeue is immediate, the broker redelivers at
907
+ #: once, and the message spins until the claim resolves — measured at
908
+ #: ~3,160 redeliveries/second from ONE duplicate, for up to
909
+ #: ``processing_timeout`` (default 300s). That burns a prefetch slot and
910
+ #: broker bandwidth, and an attacker who can publish a colliding id plus a
911
+ #: slow payload gets the amplification for free. 0 restores the old
912
+ #: behaviour.
913
+ in_flight_requeue_delay: float = 0.5
905
914
 
906
915
  def __post_init__(self) -> None:
907
916
  if self.store_results and self.mark_policy != "claim":
@@ -79,9 +79,12 @@ of (or in addition to) the defaults.
79
79
 
80
80
  from __future__ import annotations
81
81
 
82
+ import logging
82
83
  from dataclasses import dataclass
83
84
  from typing import TYPE_CHECKING, Any
84
85
 
86
+ from rabbitkit.core.sanitizer import ErrorSanitizer
87
+
85
88
  if TYPE_CHECKING:
86
89
  import structlog
87
90
 
@@ -206,6 +209,119 @@ def _redact_processor(keys: frozenset[str]) -> Any:
206
209
  return processor
207
210
 
208
211
 
212
+
213
+ # ── Value-level redaction for STDLIB logging (0.16) ───────────────────────
214
+
215
+
216
+ class SecretRedactingFilter(logging.Filter):
217
+ """Redact credential-shaped VALUES from rendered log records.
218
+
219
+ ``_redact_processor`` above scrubs by key NAME and only under structlog.
220
+ Two gaps followed from that, both of which leaked plaintext:
221
+
222
+ 1. Only four rabbitkit modules use structlog; **thirty-four use
223
+ :mod:`logging` directly** and bypassed the processor entirely.
224
+ 2. Key-name matching cannot catch a secret in the VALUE of an innocently
225
+ named field. ``logger.warning("connection lost", error=str(exc))`` has
226
+ the key ``error``, so an exception carrying
227
+ ``amqp://svc:pw@host`` sailed through.
228
+
229
+ ``ErrorSanitizer`` already knew how to redact these, but it had exactly
230
+ one call site in the whole package — the dead-letter header — so
231
+ ``error_detail="sanitized"`` protected the header while the log line,
232
+ right next to it, printed the password.
233
+
234
+ This filter closes both by redacting the *formatted* message. Attach it
235
+ to the ``rabbitkit`` logger and it covers every module regardless of
236
+ which logging API they use.
237
+
238
+ It does NOT redact tracebacks: those are rendered by the Formatter, not
239
+ the record. Use :class:`SecretRedactingFormatter` for that.
240
+ """
241
+
242
+ def __init__(self, sanitizer: ErrorSanitizer | None = None, name: str = "") -> None:
243
+ super().__init__(name)
244
+ self._sanitizer = sanitizer or ErrorSanitizer()
245
+
246
+ def filter(self, record: logging.LogRecord) -> bool:
247
+ try:
248
+ rendered = record.getMessage()
249
+ except Exception: # pragma: no cover - a broken record must still log
250
+ return True
251
+ redacted = self._sanitizer.redact(rendered)
252
+ if redacted != rendered:
253
+ # Replace msg wholesale and drop args: the substitution already
254
+ # happened, and re-applying args would raise or re-introduce the
255
+ # secret.
256
+ record.msg = redacted
257
+ record.args = ()
258
+ return True
259
+
260
+
261
+ class SecretRedactingFormatter(logging.Formatter):
262
+ """A Formatter that also redacts the rendered TRACEBACK.
263
+
264
+ The last line of a traceback is ``str(exc)``, so any handler raising
265
+ ``SQLAlchemyError("could not connect: postgres://svc:pw@db/app")`` put
266
+ the password into every ``logger.exception(...)`` and every
267
+ ``exc_info=True`` call — of which rabbitkit has roughly forty.
268
+ """
269
+
270
+ def __init__(self, *args: Any, sanitizer: ErrorSanitizer | None = None, **kwargs: Any) -> None:
271
+ super().__init__(*args, **kwargs)
272
+ self._sanitizer = sanitizer or ErrorSanitizer()
273
+
274
+ def formatException(self, ei: Any) -> str: # noqa: N802 — stdlib signature
275
+ return self._sanitizer.redact(super().formatException(ei))
276
+
277
+ def format(self, record: logging.LogRecord) -> str:
278
+ return self._sanitizer.redact(super().format(record))
279
+
280
+
281
+ def install_log_redaction(
282
+ logger_name: str = "rabbitkit",
283
+ *,
284
+ sanitizer: ErrorSanitizer | None = None,
285
+ ) -> SecretRedactingFilter:
286
+ """Attach :class:`SecretRedactingFilter` to *logger_name*, idempotently.
287
+
288
+ Called automatically by :func:`configure_structlog`. Call it directly if
289
+ you do not use rabbitkit's logging setup but still want its log lines
290
+ scrubbed.
291
+ """
292
+ root = logging.getLogger(logger_name)
293
+ for existing in root.filters:
294
+ if isinstance(existing, SecretRedactingFilter):
295
+ return existing
296
+ filt = SecretRedactingFilter(sanitizer)
297
+
298
+ # A Filter on a Logger runs only for records logged DIRECTLY on it.
299
+ # Records from child loggers propagate to ancestor HANDLERS and skip
300
+ # ancestor filters entirely, so attaching to "rabbitkit" alone would
301
+ # miss every `logging.getLogger(__name__)` in the package — which is 34
302
+ # of the 38 modules. Attach to each logger in the namespace instead.
303
+ root.addFilter(filt)
304
+ prefix = f"{logger_name}."
305
+ for name, logger in list(logging.Logger.manager.loggerDict.items()):
306
+ if not name.startswith(prefix) or not isinstance(logger, logging.Logger):
307
+ continue
308
+ if not any(isinstance(f, SecretRedactingFilter) for f in logger.filters):
309
+ logger.addFilter(filt)
310
+
311
+ # Any handler already attached under this namespace gets it too, which
312
+ # also covers loggers created AFTER this call whose records reach one of
313
+ # those handlers.
314
+ for logger in (root, *(
315
+ lg
316
+ for name, lg in logging.Logger.manager.loggerDict.items()
317
+ if name.startswith(prefix) and isinstance(lg, logging.Logger)
318
+ )):
319
+ for handler in logger.handlers:
320
+ if not any(isinstance(f, SecretRedactingFilter) for f in handler.filters):
321
+ handler.addFilter(filt)
322
+ return filt
323
+
324
+
209
325
  def configure_structlog(config: LoggingConfig | None = None) -> None:
210
326
  """One-time structlog configuration.
211
327
 
@@ -232,6 +348,10 @@ def configure_structlog(config: LoggingConfig | None = None) -> None:
232
348
 
233
349
  if config.redact_keys:
234
350
  processors.append(_redact_processor(config.redact_keys))
351
+ # Key-name redaction only reaches structlog callers (4 of 38 modules)
352
+ # and cannot see a secret in the VALUE of an innocently named field.
353
+ # This covers every rabbitkit logger regardless of logging API.
354
+ install_log_redaction()
235
355
 
236
356
  if config.add_log_level:
237
357
  processors.append(structlog.stdlib.add_log_level)
@@ -380,6 +380,28 @@ _ACK_STRATEGIES_ASYNC: dict[AckPolicy, _AsyncAckStrategy] = {
380
380
  }
381
381
 
382
382
 
383
+ #: RabbitMQ's private direct-reply-to pseudo-queue. A reply here can only
384
+ #: reach the channel that issued the request, so it is safe by construction.
385
+ _DIRECT_REPLY_PREFIX = "amq.rabbitmq.reply-to"
386
+
387
+
388
+ def _reply_to_permitted(reply_to: str, allow: tuple[str, ...] | None) -> bool:
389
+ """Is *reply_to* an acceptable destination for a handler result?
390
+
391
+ ``allow=None`` permits everything (the historical behaviour). Entries
392
+ ending in ``*`` match as prefixes.
393
+ """
394
+ if allow is None:
395
+ return True
396
+ for pattern in allow:
397
+ if pattern.endswith("*"):
398
+ if reply_to.startswith(pattern[:-1]):
399
+ return True
400
+ elif reply_to == pattern:
401
+ return True
402
+ return reply_to.startswith(_DIRECT_REPLY_PREFIX)
403
+
404
+
383
405
  class HandlerPipeline:
384
406
  """Executes the full message processing pipeline.
385
407
 
@@ -1267,6 +1289,18 @@ class HandlerPipeline:
1267
1289
 
1268
1290
  # Determine destination (Contract 5)
1269
1291
  if message.reply_to:
1292
+ # `reply_to` comes from the PUBLISHER. Publishing a handler result
1293
+ # to it unchecked is a one-hop write into any queue in the vhost.
1294
+ if not _reply_to_permitted(message.reply_to, route.reply_to_allow):
1295
+ logger.warning(
1296
+ "Route %r refused to publish its result to reply_to=%r: not in reply_to_allow. "
1297
+ "Result dropped.",
1298
+ route.name,
1299
+ message.reply_to,
1300
+ )
1301
+ return None
1302
+ if route.reply_to_allow is None and not message.reply_to.startswith(_DIRECT_REPLY_PREFIX):
1303
+ route.runtime_state.warn_unbounded_reply_to(route.name, message.reply_to)
1270
1304
  # RPC reply takes precedence
1271
1305
  if user_envelope is not None:
1272
1306
  # Preserve user-provided fields (headers, message_id,
@@ -66,6 +66,7 @@ class SubscriberRegistry:
66
66
  prefetch_count: int | None = None,
67
67
  filter_fn: Callable[[RabbitMessage], bool] | None = None,
68
68
  reject_without_dlx: str | None = None,
69
+ reply_to_allow: tuple[str, ...] | None = None,
69
70
  ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
70
71
  """Decorator to register a message handler for a queue.
71
72
 
@@ -81,6 +82,12 @@ class SubscriberRegistry:
81
82
  description: Human-readable route description.
82
83
  name: Explicit route name (auto-generated if None).
83
84
  prefetch_count: Per-route prefetch override (None=use global).
85
+ reply_to_allow: Destinations this route may publish a handler
86
+ result to via the incoming message's ``reply_to``. That field
87
+ is set by the PUBLISHER, so leaving it unrestricted makes the
88
+ route a one-hop write into any queue in the vhost. ``None``
89
+ (default) allows anything and warns once per route. Entries
90
+ ending in ``*`` match as prefixes.
84
91
  reject_without_dlx: Per-route override of
85
92
  ``SafetyConfig.reject_without_dlx`` — 'auto_provision',
86
93
  'error', or 'discard' (None=inherit broker default).
@@ -144,6 +151,7 @@ class SubscriberRegistry:
144
151
  description=description,
145
152
  filter_fn=filter_fn,
146
153
  reject_without_dlx=reject_without_dlx,
154
+ reply_to_allow=reply_to_allow,
147
155
  )
148
156
 
149
157
  # Validate at registration time (fail fast)
@@ -67,6 +67,26 @@ class RouteRuntimeState:
67
67
  """
68
68
 
69
69
  consumer_tag: str | None = None
70
+ #: One-shot latch for the unbounded-reply_to warning (see
71
+ #: RouteDefinition.reply_to_allow). Per route, so a busy queue does not
72
+ #: flood the log.
73
+ _warned_unbounded_reply_to: bool = False
74
+
75
+ def warn_unbounded_reply_to(self, route_name: str, reply_to: str) -> None:
76
+ """Warn ONCE that this route publishes results to an unrestricted
77
+ publisher-supplied destination."""
78
+ if self._warned_unbounded_reply_to:
79
+ return
80
+ self._warned_unbounded_reply_to = True
81
+ import logging
82
+
83
+ logging.getLogger(__name__).warning(
84
+ "Route %r published a handler result to reply_to=%r, which the PUBLISHER chose. "
85
+ "Without reply_to_allow this is a one-hop write into any queue in the vhost. "
86
+ "Set reply_to_allow=(...) on the subscriber to restrict it. Warned once per route.",
87
+ route_name,
88
+ reply_to,
89
+ )
70
90
 
71
91
 
72
92
  # ── Route definition ─────────────────────────────────────────────────────
@@ -124,6 +144,22 @@ class RouteDefinition:
124
144
  # Per-route override of SafetyConfig.reject_without_dlx (None = inherit)
125
145
  reject_without_dlx: str | None = None
126
146
 
147
+ # Destinations this route may publish a handler RESULT to via the
148
+ # incoming message's `reply_to`.
149
+ #
150
+ # `reply_to` is set by the PUBLISHER and was used verbatim as a routing
151
+ # key on the default exchange, which makes it a one-hop write primitive:
152
+ # a publisher who can reach one queue could have this route's handler
153
+ # output delivered into any queue in the vhost, including queues they
154
+ # have no publish rights to. Combined with a dedup result store, it also
155
+ # let a replayed message return someone else's stored result.
156
+ #
157
+ # None (default) keeps the previous behaviour and warns ONCE per route
158
+ # when a reply goes somewhere other than the broker's private
159
+ # direct-reply-to pseudo-queue. Set a tuple to enforce; entries ending in
160
+ # "*" are treated as prefixes.
161
+ reply_to_allow: tuple[str, ...] | None = None
162
+
127
163
  # ── Runtime state (mutable sub-object; populated by broker) ──
128
164
  runtime_state: RouteRuntimeState = field(default_factory=RouteRuntimeState)
129
165
 
@@ -81,6 +81,7 @@ class RabbitRouter:
81
81
  prefetch_count: int | None = None,
82
82
  filter_fn: Callable[[RabbitMessage], bool] | None = None,
83
83
  reject_without_dlx: str | None = None,
84
+ reply_to_allow: tuple[str, ...] | None = None,
84
85
  ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
85
86
  """Register a subscriber on this router.
86
87
 
@@ -125,6 +126,7 @@ class RabbitRouter:
125
126
  prefetch_count=prefetch_count,
126
127
  filter_fn=filter_fn,
127
128
  reject_without_dlx=reject_without_dlx,
129
+ reply_to_allow=reply_to_allow,
128
130
  )
129
131
 
130
132
  def publisher(
@@ -35,7 +35,13 @@ _DEFAULT_PATTERNS: tuple[re.Pattern[str], ...] = (
35
35
  # scheme://user:password@host → scheme://***:***@host
36
36
  # password is greedy up to the LAST "@" before the host so an unescaped
37
37
  # "@" inside the secret cannot leak its tail
38
- re.compile(r"(?i)\b([a-z][a-z0-9+.-]*://)([^/\s:@]+):([^/\s]+)@"),
38
+ # The scheme run is bounded ({0,31}) on purpose. With an unbounded `*`
39
+ # this pattern is QUADRATIC: a long run of scheme-legal characters that
40
+ # never reaches "://" makes the engine retry from every offset. Measured
41
+ # before the bound: 8 KiB 0.06s, 16 KiB 0.25s, 32 KiB 0.96s, 64 KiB 3.8s
42
+ # — 4x per doubling, i.e. ~16 minutes for 1 MiB, on the event loop.
43
+ # Real URI schemes are a handful of characters (RFC 3986).
44
+ re.compile(r"(?i)\b([a-z][a-z0-9+.-]{0,31}://)([^/\s:@]{1,256}):([^/\s]{1,512})@"),
39
45
  # Authorization: Bearer <token> / Basic <blob>
40
46
  re.compile(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9\-._~+/]+=*"),
41
47
  # key=value / key: value / "key": "value" for secret-bearing key names
@@ -53,6 +59,9 @@ _DEFAULT_PATTERNS: tuple[re.Pattern[str], ...] = (
53
59
 
54
60
  _CODE_SAFE = re.compile(r"[^A-Za-z0-9_.]")
55
61
 
62
+ #: How much more than the kept summary to redact over. See summary_for().
63
+ _REDACT_WINDOW_FACTOR = 8
64
+
56
65
 
57
66
  @dataclass(frozen=True, slots=True)
58
67
  class SanitizedError:
@@ -142,7 +151,20 @@ class ErrorSanitizer:
142
151
  # Collapse whitespace/newlines: a header is one line; a stack-trace-
143
152
  # shaped message must not smuggle formatting into log pipelines.
144
153
  text = " ".join(text.split())
145
- return self.redact(text)[: self._max_summary]
154
+ # Bound the input BEFORE redacting. redact() runs six regexes over
155
+ # whatever it is given, and `str(exc)` is attacker-influenced whenever
156
+ # a handler echoes the body (json.JSONDecodeError, pydantic's
157
+ # ValidationError, ValueError(f"bad: {body}")). Redacting a 16 MiB
158
+ # message to keep 256 characters is pure waste, and on the async path
159
+ # it blocks the event loop, which costs heartbeats and wedges the
160
+ # consumer into a redelivery loop.
161
+ #
162
+ # The window is a MULTIPLE of the kept length, not the kept length:
163
+ # truncating to exactly _max_summary could slice a secret that starts
164
+ # just inside the kept region, leaving its prefix unredacted. With 8x
165
+ # slack, any secret beginning within the kept 256 characters is still
166
+ # seen whole by the patterns.
167
+ return self.redact(text[: self._max_summary * _REDACT_WINDOW_FACTOR])[: self._max_summary]
146
168
 
147
169
  def sanitize(self, exc: BaseException, severity: ErrorSeverity | None = None) -> SanitizedError:
148
170
  category = severity.value if severity is not None else "unknown"