dory-processor-sdk 0.0.1__py3-none-any.whl
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.
- dory/__init__.py +101 -0
- dory/auth/__init__.py +10 -0
- dory/auth/oauth2.py +153 -0
- dory/auto_instrument.py +142 -0
- dory/cli/__init__.py +5 -0
- dory/cli/main.py +137 -0
- dory/cli/templates.py +123 -0
- dory/config/__init__.py +23 -0
- dory/config/defaults.py +24 -0
- dory/config/loader.py +430 -0
- dory/config/presets.py +73 -0
- dory/config/schema.py +84 -0
- dory/core/__init__.py +27 -0
- dory/core/app.py +434 -0
- dory/core/context.py +209 -0
- dory/core/lifecycle.py +214 -0
- dory/core/meta.py +121 -0
- dory/core/modes.py +479 -0
- dory/core/processor.py +564 -0
- dory/core/signals.py +122 -0
- dory/decorators.py +142 -0
- dory/edge/__init__.py +88 -0
- dory/edge/adaptive.py +644 -0
- dory/edge/detector.py +546 -0
- dory/edge/fencing.py +488 -0
- dory/edge/heartbeat.py +598 -0
- dory/edge/role.py +419 -0
- dory/errors/__init__.py +139 -0
- dory/errors/classification.py +362 -0
- dory/errors/codes.py +498 -0
- dory/geo/__init__.py +40 -0
- dory/geo/geolocalizer.py +1034 -0
- dory/health/__init__.py +12 -0
- dory/health/probes.py +210 -0
- dory/health/server.py +635 -0
- dory/k8s/__init__.py +80 -0
- dory/k8s/annotation_watcher.py +184 -0
- dory/k8s/client.py +251 -0
- dory/k8s/labels.py +505 -0
- dory/k8s/pod_metadata.py +182 -0
- dory/logging/__init__.py +9 -0
- dory/logging/logger.py +148 -0
- dory/metrics/__init__.py +7 -0
- dory/metrics/collector.py +301 -0
- dory/middleware/__init__.py +46 -0
- dory/middleware/connection_tracker.py +608 -0
- dory/middleware/request_id.py +325 -0
- dory/middleware/request_tracker.py +511 -0
- dory/migration/__init__.py +33 -0
- dory/migration/configmap.py +232 -0
- dory/migration/s3_store.py +594 -0
- dory/migration/serialization.py +135 -0
- dory/migration/state_manager.py +286 -0
- dory/migration/transfer.py +382 -0
- dory/monitoring/__init__.py +29 -0
- dory/monitoring/opentelemetry.py +489 -0
- dory/output/__init__.py +31 -0
- dory/output/envelope.py +137 -0
- dory/output/formatter.py +113 -0
- dory/output/rabbitmq.py +632 -0
- dory/output/routing.py +318 -0
- dory/output/validator.py +199 -0
- dory/py.typed +2 -0
- dory/recovery/__init__.py +60 -0
- dory/recovery/golden_image.py +487 -0
- dory/recovery/golden_snapshot.py +713 -0
- dory/recovery/golden_validator.py +518 -0
- dory/recovery/partial_recovery.py +482 -0
- dory/recovery/recovery_decision.py +242 -0
- dory/recovery/restart_detector.py +142 -0
- dory/recovery/state_validator.py +183 -0
- dory/resilience/__init__.py +45 -0
- dory/resilience/circuit_breaker.py +457 -0
- dory/resilience/retry.py +389 -0
- dory/simple.py +342 -0
- dory/types.py +68 -0
- dory/utils/__init__.py +31 -0
- dory/utils/errors.py +59 -0
- dory/utils/retry.py +115 -0
- dory/utils/timeout.py +80 -0
- dory_processor_sdk-0.0.1.dist-info/METADATA +424 -0
- dory_processor_sdk-0.0.1.dist-info/RECORD +86 -0
- dory_processor_sdk-0.0.1.dist-info/WHEEL +5 -0
- dory_processor_sdk-0.0.1.dist-info/entry_points.txt +2 -0
- dory_processor_sdk-0.0.1.dist-info/licenses/LICENSE +201 -0
- dory_processor_sdk-0.0.1.dist-info/top_level.txt +1 -0
dory/output/rabbitmq.py
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RabbitMQ publisher with circuit breaker, retry, and in-memory buffering.
|
|
3
|
+
|
|
4
|
+
Provides a resilient async publisher for sending processor output to RabbitMQ.
|
|
5
|
+
Requires the ``aio-pika`` optional dependency: ``pip install dory-processor-sdk[production]``
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
from collections import deque
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Awaitable, Callable, Optional
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
import aio_pika
|
|
17
|
+
import yarl
|
|
18
|
+
|
|
19
|
+
from dory.output.formatter import JSONFormatter, OutputFormatter
|
|
20
|
+
from dory.resilience import CircuitBreaker, CircuitOpenError, RetryExhaustedError
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PublishError(Exception):
|
|
26
|
+
"""Raised when a publish operation fails."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _mask_url(url: str) -> str:
|
|
30
|
+
"""Mask credentials in an AMQP URL for safe logging.
|
|
31
|
+
|
|
32
|
+
Returns only the host (and port if present), stripping user/pass/vhost.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
parsed = urlparse(url)
|
|
36
|
+
host = parsed.hostname or "unknown"
|
|
37
|
+
if parsed.port:
|
|
38
|
+
return f"{host}:{parsed.port}"
|
|
39
|
+
return host
|
|
40
|
+
except Exception:
|
|
41
|
+
return "<masked>"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class PublisherConfig:
|
|
46
|
+
"""Configuration for RabbitMQ publisher.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
url: AMQP connection URL.
|
|
50
|
+
exchange: Default exchange name.
|
|
51
|
+
exchange_type: Exchange type (topic, direct, fanout, headers).
|
|
52
|
+
durable: Whether the exchange is durable.
|
|
53
|
+
connection_timeout: Connection timeout in seconds.
|
|
54
|
+
heartbeat: AMQP heartbeat interval in seconds.
|
|
55
|
+
buffer_enabled: Enable in-memory message buffering on failure.
|
|
56
|
+
buffer_max_size: Maximum number of buffered messages.
|
|
57
|
+
buffer_max_bytes: Maximum total size of buffered messages in bytes.
|
|
58
|
+
retry_max_attempts: Max retry attempts per publish.
|
|
59
|
+
retry_initial_delay: Initial retry delay in seconds.
|
|
60
|
+
retry_max_delay: Maximum retry delay in seconds.
|
|
61
|
+
circuit_breaker_failure_threshold: Failures before circuit opens.
|
|
62
|
+
circuit_breaker_timeout: Seconds before circuit half-opens.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
url: str = "amqp://guest:guest@localhost:5672/"
|
|
66
|
+
exchange: str = "dory.output"
|
|
67
|
+
exchange_type: str = "topic"
|
|
68
|
+
durable: bool = True
|
|
69
|
+
connection_timeout: float = 10.0
|
|
70
|
+
heartbeat: int = 60
|
|
71
|
+
buffer_enabled: bool = True
|
|
72
|
+
buffer_max_size: int = 10000
|
|
73
|
+
buffer_max_bytes: int = 100 * 1024 * 1024 # 100MB
|
|
74
|
+
retry_max_attempts: int = 3
|
|
75
|
+
retry_initial_delay: float = 1.0
|
|
76
|
+
retry_max_delay: float = 30.0
|
|
77
|
+
circuit_breaker_failure_threshold: int = 5
|
|
78
|
+
circuit_breaker_timeout: float = 60.0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class BufferedMessage:
|
|
83
|
+
"""A message buffered for later delivery.
|
|
84
|
+
|
|
85
|
+
Attributes:
|
|
86
|
+
exchange: Target exchange name.
|
|
87
|
+
routing_key: Message routing key.
|
|
88
|
+
body: Serialized message body.
|
|
89
|
+
headers: Optional message headers.
|
|
90
|
+
timestamp: Time the message was originally published.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
exchange: str
|
|
94
|
+
routing_key: str
|
|
95
|
+
body: bytes
|
|
96
|
+
headers: dict[str, Any] = field(default_factory=dict)
|
|
97
|
+
timestamp: float = field(default_factory=time.time)
|
|
98
|
+
|
|
99
|
+
def size_bytes(self) -> int:
|
|
100
|
+
"""Return approximate size of this message in bytes."""
|
|
101
|
+
header_size = sum(
|
|
102
|
+
len(str(k)) + len(str(v)) for k, v in self.headers.items()
|
|
103
|
+
) if self.headers else 0
|
|
104
|
+
return len(self.body) + len(self.routing_key) + len(self.exchange) + header_size
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class RabbitMQPublisher:
|
|
108
|
+
"""Async RabbitMQ publisher with circuit breaker, retry, and buffering.
|
|
109
|
+
|
|
110
|
+
Publishes formatted messages to RabbitMQ with full resilience:
|
|
111
|
+
- Circuit breaker wraps retry to prevent cascading failures
|
|
112
|
+
- Retry with exponential backoff for transient errors
|
|
113
|
+
- In-memory buffer for messages that cannot be delivered
|
|
114
|
+
- Automatic buffer flush on reconnection
|
|
115
|
+
- MessageEnvelope wrapping for unified output
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
config: Publisher configuration.
|
|
119
|
+
formatter: Inner serializer (defaults to JSONFormatter). Wrapped in
|
|
120
|
+
an EnvelopeFormatter automatically.
|
|
121
|
+
metrics: Optional MetricsCollector for recording publish metrics.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
config: PublisherConfig | None = None,
|
|
127
|
+
formatter: OutputFormatter | None = None,
|
|
128
|
+
metrics: Any | None = None,
|
|
129
|
+
url_provider: Callable[[], Any] | None = None,
|
|
130
|
+
**kwargs: Any,
|
|
131
|
+
):
|
|
132
|
+
self._config = config or PublisherConfig()
|
|
133
|
+
self._url_provider = url_provider
|
|
134
|
+
self._formatter = formatter or JSONFormatter()
|
|
135
|
+
self._metrics = metrics
|
|
136
|
+
|
|
137
|
+
# Envelope wrapping (always on)
|
|
138
|
+
from dory.output.envelope import EnvelopeFormatter, ENVELOPE_SCHEMA_VERSION
|
|
139
|
+
|
|
140
|
+
self._envelope_formatter = EnvelopeFormatter(
|
|
141
|
+
formatter=self._formatter,
|
|
142
|
+
schema_version=ENVELOPE_SCHEMA_VERSION,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Connection state
|
|
146
|
+
self._connection: Optional[aio_pika.abc.AbstractRobustConnection] = None
|
|
147
|
+
self._channel: Optional[aio_pika.abc.AbstractChannel] = None
|
|
148
|
+
self._exchange: Optional[aio_pika.abc.AbstractExchange] = None
|
|
149
|
+
self._connected = False
|
|
150
|
+
self._closing = False
|
|
151
|
+
|
|
152
|
+
# Buffer
|
|
153
|
+
self._buffer: deque[BufferedMessage] = deque()
|
|
154
|
+
self._buffer_bytes: int = 0
|
|
155
|
+
self._buffer_lock = asyncio.Lock()
|
|
156
|
+
|
|
157
|
+
# Circuit breaker (wraps retry internally)
|
|
158
|
+
self._circuit_breaker = CircuitBreaker(
|
|
159
|
+
name="rabbitmq_publisher",
|
|
160
|
+
failure_threshold=self._config.circuit_breaker_failure_threshold,
|
|
161
|
+
timeout_seconds=self._config.circuit_breaker_timeout,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Metrics counters
|
|
165
|
+
self._publish_total = 0
|
|
166
|
+
self._publish_success = 0
|
|
167
|
+
self._publish_failed = 0
|
|
168
|
+
self._publish_buffered = 0
|
|
169
|
+
self._buffer_dropped = 0
|
|
170
|
+
|
|
171
|
+
masked = _mask_url(self._config.url)
|
|
172
|
+
logger.info(f"RabbitMQ publisher created for {masked}")
|
|
173
|
+
|
|
174
|
+
async def connect(self) -> None:
|
|
175
|
+
"""Establish connection to RabbitMQ.
|
|
176
|
+
|
|
177
|
+
Creates a robust connection, opens a channel, and declares the exchange.
|
|
178
|
+
If a ``url_provider`` was given, it is called to obtain the current URL
|
|
179
|
+
before connecting (e.g., to refresh an OAuth2 token). A close callback
|
|
180
|
+
is also registered so that ``aio_pika``'s automatic reconnection uses a
|
|
181
|
+
freshly-fetched URL (with a new token) instead of the stale original.
|
|
182
|
+
Automatically flushes any buffered messages after reconnection.
|
|
183
|
+
"""
|
|
184
|
+
if self._url_provider is not None:
|
|
185
|
+
self._config.url = await self._url_provider()
|
|
186
|
+
|
|
187
|
+
masked = _mask_url(self._config.url)
|
|
188
|
+
logger.info(f"Connecting to RabbitMQ at {masked}")
|
|
189
|
+
|
|
190
|
+
self._connection = await aio_pika.connect_robust(
|
|
191
|
+
self._config.url,
|
|
192
|
+
timeout=self._config.connection_timeout,
|
|
193
|
+
heartbeat=self._config.heartbeat,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# When url_provider is set (e.g. OAuth2), register a close callback
|
|
197
|
+
# that refreshes the connection URL before aio-pika's automatic
|
|
198
|
+
# reconnection factory retries. Execution order on disconnect:
|
|
199
|
+
# 1. close_callbacks fire (async, awaited)
|
|
200
|
+
# 2. __connection_close_event wakes the reconnection loop
|
|
201
|
+
# 3. reconnection loop calls Connection.connect() using self.url
|
|
202
|
+
# So updating connection.url here ensures a fresh token is used.
|
|
203
|
+
if self._url_provider is not None:
|
|
204
|
+
self._connection.close_callbacks.add(self._refresh_url_on_close)
|
|
205
|
+
|
|
206
|
+
self._channel = await self._connection.channel()
|
|
207
|
+
try:
|
|
208
|
+
self._exchange = await self._channel.declare_exchange(
|
|
209
|
+
self._config.exchange,
|
|
210
|
+
type=aio_pika.ExchangeType(self._config.exchange_type),
|
|
211
|
+
durable=self._config.durable,
|
|
212
|
+
)
|
|
213
|
+
except Exception as e:
|
|
214
|
+
# If declare fails (e.g. OAuth2 user lacks configure permission),
|
|
215
|
+
# fall back to using the exchange without declaring it.
|
|
216
|
+
logger.info(
|
|
217
|
+
f"Exchange declare skipped (using existing): {e}"
|
|
218
|
+
)
|
|
219
|
+
self._exchange = await self._channel.get_exchange(
|
|
220
|
+
self._config.exchange, ensure=False,
|
|
221
|
+
)
|
|
222
|
+
self._connected = True
|
|
223
|
+
|
|
224
|
+
self._record_metric("connection_status", gauge=1.0)
|
|
225
|
+
logger.info(f"Connected to RabbitMQ at {masked}")
|
|
226
|
+
|
|
227
|
+
# Flush buffered messages
|
|
228
|
+
if self._buffer:
|
|
229
|
+
logger.info(f"Flushing {len(self._buffer)} buffered messages")
|
|
230
|
+
await self.flush_buffer()
|
|
231
|
+
|
|
232
|
+
async def _refresh_url_on_close(
|
|
233
|
+
self,
|
|
234
|
+
_sender: Any,
|
|
235
|
+
_exc: BaseException | None = None,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Close callback that refreshes the AMQP URL for reconnection.
|
|
238
|
+
|
|
239
|
+
Called by ``aio_pika`` when the connection drops, *before* the
|
|
240
|
+
reconnection factory loop retries. Fetches a new token via the
|
|
241
|
+
``url_provider`` and updates ``connection.url`` so the next
|
|
242
|
+
reconnection attempt uses valid credentials.
|
|
243
|
+
"""
|
|
244
|
+
if self._url_provider is None or self._closing:
|
|
245
|
+
return
|
|
246
|
+
try:
|
|
247
|
+
fresh_url = await self._url_provider()
|
|
248
|
+
self._config.url = fresh_url
|
|
249
|
+
if self._connection is not None:
|
|
250
|
+
self._connection.url = yarl.URL(fresh_url)
|
|
251
|
+
logger.info(
|
|
252
|
+
f"Refreshed AMQP URL for reconnection to "
|
|
253
|
+
f"{_mask_url(fresh_url)}"
|
|
254
|
+
)
|
|
255
|
+
except Exception as e:
|
|
256
|
+
logger.warning(f"Failed to refresh AMQP URL on disconnect: {e}")
|
|
257
|
+
|
|
258
|
+
async def publish(
|
|
259
|
+
self,
|
|
260
|
+
routing_key: str,
|
|
261
|
+
data: Any,
|
|
262
|
+
exchange: str | None = None,
|
|
263
|
+
headers: dict[str, Any] | None = None,
|
|
264
|
+
raw: bool = False,
|
|
265
|
+
**kwargs: Any,
|
|
266
|
+
) -> None:
|
|
267
|
+
"""Publish data to RabbitMQ.
|
|
268
|
+
|
|
269
|
+
When ``raw=False`` (default), data is wrapped in a MessageEnvelope
|
|
270
|
+
before publishing. When ``raw=True``, data is serialized as-is
|
|
271
|
+
(caller is responsible for providing a properly formatted payload,
|
|
272
|
+
e.g. an envelope dict).
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
routing_key: Message routing key.
|
|
276
|
+
data: Data to publish.
|
|
277
|
+
exchange: Optional exchange override.
|
|
278
|
+
headers: Optional message headers.
|
|
279
|
+
raw: If True, skip envelope wrapping and serialize data directly.
|
|
280
|
+
|
|
281
|
+
Raises:
|
|
282
|
+
PublishError: If publish fails and buffering is disabled.
|
|
283
|
+
"""
|
|
284
|
+
if self._closing:
|
|
285
|
+
logger.warning("Publisher is closing, cannot accept new messages")
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
self._publish_total += 1
|
|
289
|
+
self._record_metric("publish_total", counter=1)
|
|
290
|
+
|
|
291
|
+
target_exchange = exchange or self._config.exchange
|
|
292
|
+
|
|
293
|
+
# Format data (envelope wrapping unless raw=True)
|
|
294
|
+
try:
|
|
295
|
+
if raw:
|
|
296
|
+
body = self._formatter.format(data)
|
|
297
|
+
else:
|
|
298
|
+
body = self._envelope_formatter.format(data)
|
|
299
|
+
except Exception as e:
|
|
300
|
+
self._publish_failed += 1
|
|
301
|
+
self._record_metric("publish_failed_total", counter=1)
|
|
302
|
+
raise PublishError(f"Failed to format message: {e}") from e
|
|
303
|
+
|
|
304
|
+
# Try publishing through circuit breaker (which wraps retry)
|
|
305
|
+
try:
|
|
306
|
+
await self._circuit_breaker.call(
|
|
307
|
+
self._publish_with_retry,
|
|
308
|
+
target_exchange,
|
|
309
|
+
routing_key,
|
|
310
|
+
body,
|
|
311
|
+
headers,
|
|
312
|
+
)
|
|
313
|
+
self._publish_success += 1
|
|
314
|
+
self._record_metric("publish_success_total", counter=1)
|
|
315
|
+
except CircuitOpenError:
|
|
316
|
+
logger.warning(
|
|
317
|
+
f"Circuit breaker open, buffering message for {routing_key}"
|
|
318
|
+
)
|
|
319
|
+
await self._buffer_message(
|
|
320
|
+
target_exchange, routing_key, body, headers or {}
|
|
321
|
+
)
|
|
322
|
+
except (PublishError, RetryExhaustedError) as e:
|
|
323
|
+
logger.warning(f"Publish failed after retries: {e}")
|
|
324
|
+
self._publish_failed += 1
|
|
325
|
+
self._record_metric("publish_failed_total", counter=1)
|
|
326
|
+
if self._config.buffer_enabled:
|
|
327
|
+
await self._buffer_message(
|
|
328
|
+
target_exchange, routing_key, body, headers or {}
|
|
329
|
+
)
|
|
330
|
+
else:
|
|
331
|
+
raise PublishError(f"Publish failed: {e}") from e
|
|
332
|
+
except Exception as e:
|
|
333
|
+
logger.warning(f"Publish failed: {e}")
|
|
334
|
+
self._publish_failed += 1
|
|
335
|
+
self._record_metric("publish_failed_total", counter=1)
|
|
336
|
+
if self._config.buffer_enabled:
|
|
337
|
+
await self._buffer_message(
|
|
338
|
+
target_exchange, routing_key, body, headers or {}
|
|
339
|
+
)
|
|
340
|
+
else:
|
|
341
|
+
raise PublishError(f"Publish failed: {e}") from e
|
|
342
|
+
|
|
343
|
+
@staticmethod
|
|
344
|
+
def _is_token_expiry_error(error: Exception) -> bool:
|
|
345
|
+
"""Check if an error indicates an expired OAuth2/JWT token."""
|
|
346
|
+
msg = str(error).lower()
|
|
347
|
+
return "access_refused" in msg and (
|
|
348
|
+
"token has expired" in msg
|
|
349
|
+
or "jwt" in msg and "expired" in msg
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
async def _reconnect_with_fresh_token(self) -> None:
|
|
353
|
+
"""Close the current connection and reconnect with a fresh token.
|
|
354
|
+
|
|
355
|
+
Called when a publish fails due to an expired JWT while the AMQP
|
|
356
|
+
connection is still alive. Fetches a new token via ``url_provider``,
|
|
357
|
+
then re-establishes the channel and exchange.
|
|
358
|
+
"""
|
|
359
|
+
if self._url_provider is None:
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
logger.info("Token expired — refreshing token and reconnecting")
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
# Fetch fresh URL (triggers OAuth2TokenProvider.get_token())
|
|
366
|
+
fresh_url = await self._url_provider()
|
|
367
|
+
self._config.url = fresh_url
|
|
368
|
+
except Exception as e:
|
|
369
|
+
logger.warning(f"Failed to refresh token: {e}")
|
|
370
|
+
raise
|
|
371
|
+
|
|
372
|
+
# Close existing connection (suppress errors on stale state)
|
|
373
|
+
if self._connection is not None:
|
|
374
|
+
try:
|
|
375
|
+
await self._connection.close()
|
|
376
|
+
except Exception:
|
|
377
|
+
pass
|
|
378
|
+
self._connection = None
|
|
379
|
+
self._channel = None
|
|
380
|
+
self._exchange = None
|
|
381
|
+
self._connected = False
|
|
382
|
+
|
|
383
|
+
# Reconnect with the fresh token
|
|
384
|
+
await self.connect()
|
|
385
|
+
logger.info("Reconnected with fresh token")
|
|
386
|
+
|
|
387
|
+
async def _publish_with_retry(
|
|
388
|
+
self,
|
|
389
|
+
exchange_name: str,
|
|
390
|
+
routing_key: str,
|
|
391
|
+
body: bytes,
|
|
392
|
+
headers: dict[str, Any] | None,
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Publish with retry logic. Called inside circuit breaker.
|
|
395
|
+
|
|
396
|
+
If a publish fails due to an expired JWT token and a ``url_provider``
|
|
397
|
+
is configured, the connection is torn down and re-established with a
|
|
398
|
+
fresh token before retrying.
|
|
399
|
+
"""
|
|
400
|
+
last_error: Exception | None = None
|
|
401
|
+
delay = self._config.retry_initial_delay
|
|
402
|
+
token_refreshed = False
|
|
403
|
+
|
|
404
|
+
for attempt in range(self._config.retry_max_attempts):
|
|
405
|
+
try:
|
|
406
|
+
await self._publish_internal(
|
|
407
|
+
exchange_name, routing_key, body, headers
|
|
408
|
+
)
|
|
409
|
+
return
|
|
410
|
+
except Exception as e:
|
|
411
|
+
last_error = e
|
|
412
|
+
|
|
413
|
+
# On token expiry, reconnect with a fresh token once
|
|
414
|
+
if (
|
|
415
|
+
not token_refreshed
|
|
416
|
+
and self._url_provider is not None
|
|
417
|
+
and self._is_token_expiry_error(e)
|
|
418
|
+
):
|
|
419
|
+
try:
|
|
420
|
+
await self._reconnect_with_fresh_token()
|
|
421
|
+
token_refreshed = True
|
|
422
|
+
# Retry immediately after reconnect (no backoff)
|
|
423
|
+
continue
|
|
424
|
+
except Exception as refresh_err:
|
|
425
|
+
logger.warning(f"Token refresh failed: {refresh_err}")
|
|
426
|
+
|
|
427
|
+
if attempt < self._config.retry_max_attempts - 1:
|
|
428
|
+
logger.warning(
|
|
429
|
+
f"Publish attempt {attempt + 1}/{self._config.retry_max_attempts} "
|
|
430
|
+
f"failed: {e}. Retrying in {delay:.1f}s"
|
|
431
|
+
)
|
|
432
|
+
await asyncio.sleep(delay)
|
|
433
|
+
delay = min(delay * 2, self._config.retry_max_delay)
|
|
434
|
+
|
|
435
|
+
raise PublishError(
|
|
436
|
+
f"Publish failed after {self._config.retry_max_attempts} attempts: {last_error}"
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
async def _publish_internal(
|
|
440
|
+
self,
|
|
441
|
+
exchange_name: str,
|
|
442
|
+
routing_key: str,
|
|
443
|
+
body: bytes,
|
|
444
|
+
headers: dict[str, Any] | None,
|
|
445
|
+
) -> None:
|
|
446
|
+
"""Perform the actual AMQP publish."""
|
|
447
|
+
if not self._connected or self._exchange is None:
|
|
448
|
+
raise PublishError("Not connected to RabbitMQ")
|
|
449
|
+
|
|
450
|
+
# Use declared exchange if name matches, otherwise get exchange by name
|
|
451
|
+
if exchange_name == self._config.exchange and self._exchange is not None:
|
|
452
|
+
exchange = self._exchange
|
|
453
|
+
else:
|
|
454
|
+
if self._channel is None:
|
|
455
|
+
raise PublishError("Channel not available")
|
|
456
|
+
exchange = await self._channel.declare_exchange(
|
|
457
|
+
exchange_name,
|
|
458
|
+
type=aio_pika.ExchangeType(self._config.exchange_type),
|
|
459
|
+
durable=self._config.durable,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
message = aio_pika.Message(
|
|
463
|
+
body=body,
|
|
464
|
+
content_type=self._formatter.content_type,
|
|
465
|
+
headers=headers or {},
|
|
466
|
+
timestamp=time.time(),
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
logger.info(
|
|
470
|
+
"AMQP publish exchange=%s routing_key=%s size=%d bytes",
|
|
471
|
+
exchange_name,
|
|
472
|
+
routing_key,
|
|
473
|
+
len(body),
|
|
474
|
+
)
|
|
475
|
+
await exchange.publish(message, routing_key=routing_key)
|
|
476
|
+
|
|
477
|
+
async def _buffer_message(
|
|
478
|
+
self,
|
|
479
|
+
exchange: str,
|
|
480
|
+
routing_key: str,
|
|
481
|
+
body: bytes,
|
|
482
|
+
headers: dict[str, Any],
|
|
483
|
+
) -> None:
|
|
484
|
+
"""Buffer a message for later delivery."""
|
|
485
|
+
if not self._config.buffer_enabled:
|
|
486
|
+
return
|
|
487
|
+
|
|
488
|
+
msg = BufferedMessage(
|
|
489
|
+
exchange=exchange,
|
|
490
|
+
routing_key=routing_key,
|
|
491
|
+
body=body,
|
|
492
|
+
headers=headers,
|
|
493
|
+
)
|
|
494
|
+
msg_size = msg.size_bytes()
|
|
495
|
+
|
|
496
|
+
async with self._buffer_lock:
|
|
497
|
+
# Evict oldest messages if buffer is full (by count)
|
|
498
|
+
while (
|
|
499
|
+
len(self._buffer) >= self._config.buffer_max_size
|
|
500
|
+
and self._buffer
|
|
501
|
+
):
|
|
502
|
+
evicted = self._buffer.popleft()
|
|
503
|
+
self._buffer_bytes -= evicted.size_bytes()
|
|
504
|
+
self._buffer_dropped += 1
|
|
505
|
+
logger.warning(
|
|
506
|
+
f"Buffer full (max {self._config.buffer_max_size} messages), "
|
|
507
|
+
f"dropping oldest message for {evicted.routing_key}"
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# Evict oldest messages if buffer is full (by bytes)
|
|
511
|
+
while (
|
|
512
|
+
self._config.buffer_max_bytes > 0
|
|
513
|
+
and self._buffer_bytes + msg_size > self._config.buffer_max_bytes
|
|
514
|
+
and self._buffer
|
|
515
|
+
):
|
|
516
|
+
evicted = self._buffer.popleft()
|
|
517
|
+
self._buffer_bytes -= evicted.size_bytes()
|
|
518
|
+
self._buffer_dropped += 1
|
|
519
|
+
logger.warning(
|
|
520
|
+
f"Buffer full (max {self._config.buffer_max_bytes} bytes), "
|
|
521
|
+
f"dropping oldest message for {evicted.routing_key}"
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
self._buffer.append(msg)
|
|
525
|
+
self._buffer_bytes += msg_size
|
|
526
|
+
|
|
527
|
+
self._publish_buffered += 1
|
|
528
|
+
self._record_metric("publish_buffered_total", counter=1)
|
|
529
|
+
self._record_metric("buffer_size_messages", gauge=float(len(self._buffer)))
|
|
530
|
+
self._record_metric("buffer_size_bytes", gauge=float(self._buffer_bytes))
|
|
531
|
+
|
|
532
|
+
async def flush_buffer(self) -> None:
|
|
533
|
+
"""Flush all buffered messages to RabbitMQ.
|
|
534
|
+
|
|
535
|
+
Messages that fail to publish remain in the buffer.
|
|
536
|
+
"""
|
|
537
|
+
if not self._buffer:
|
|
538
|
+
return
|
|
539
|
+
|
|
540
|
+
async with self._buffer_lock:
|
|
541
|
+
remaining: deque[BufferedMessage] = deque()
|
|
542
|
+
remaining_bytes = 0
|
|
543
|
+
|
|
544
|
+
while self._buffer:
|
|
545
|
+
msg = self._buffer.popleft()
|
|
546
|
+
self._buffer_bytes -= msg.size_bytes()
|
|
547
|
+
|
|
548
|
+
try:
|
|
549
|
+
await self._publish_internal(
|
|
550
|
+
msg.exchange, msg.routing_key, msg.body, msg.headers
|
|
551
|
+
)
|
|
552
|
+
except Exception as e:
|
|
553
|
+
logger.warning(
|
|
554
|
+
f"Failed to flush buffered message for {msg.routing_key}: {e}"
|
|
555
|
+
)
|
|
556
|
+
remaining.append(msg)
|
|
557
|
+
remaining_bytes += msg.size_bytes()
|
|
558
|
+
|
|
559
|
+
# Put failed messages back
|
|
560
|
+
self._buffer = remaining
|
|
561
|
+
self._buffer_bytes = remaining_bytes
|
|
562
|
+
|
|
563
|
+
self._record_metric("buffer_size_messages", gauge=float(len(self._buffer)))
|
|
564
|
+
self._record_metric("buffer_size_bytes", gauge=float(self._buffer_bytes))
|
|
565
|
+
|
|
566
|
+
async def close(self) -> None:
|
|
567
|
+
"""Close the publisher, flushing the buffer first."""
|
|
568
|
+
if self._closing:
|
|
569
|
+
return
|
|
570
|
+
self._closing = True
|
|
571
|
+
|
|
572
|
+
logger.info("Closing RabbitMQ publisher")
|
|
573
|
+
|
|
574
|
+
# Flush buffer before closing
|
|
575
|
+
if self._buffer and self._connected:
|
|
576
|
+
try:
|
|
577
|
+
await self.flush_buffer()
|
|
578
|
+
except Exception as e:
|
|
579
|
+
logger.warning(f"Error flushing buffer during close: {e}")
|
|
580
|
+
|
|
581
|
+
# Close channel and connection
|
|
582
|
+
if self._channel is not None:
|
|
583
|
+
try:
|
|
584
|
+
await self._channel.close()
|
|
585
|
+
except Exception:
|
|
586
|
+
pass
|
|
587
|
+
self._channel = None
|
|
588
|
+
|
|
589
|
+
if self._connection is not None:
|
|
590
|
+
try:
|
|
591
|
+
await self._connection.close()
|
|
592
|
+
except Exception:
|
|
593
|
+
pass
|
|
594
|
+
self._connection = None
|
|
595
|
+
|
|
596
|
+
self._connected = False
|
|
597
|
+
self._exchange = None
|
|
598
|
+
self._record_metric("connection_status", gauge=0.0)
|
|
599
|
+
logger.info("RabbitMQ publisher closed")
|
|
600
|
+
|
|
601
|
+
def is_connected(self) -> bool:
|
|
602
|
+
"""Check if the publisher is currently connected."""
|
|
603
|
+
return self._connected
|
|
604
|
+
|
|
605
|
+
def get_buffer_size(self) -> dict[str, int]:
|
|
606
|
+
"""Get current buffer size.
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
Dictionary with 'messages' count and 'bytes' total size.
|
|
610
|
+
"""
|
|
611
|
+
return {
|
|
612
|
+
"messages": len(self._buffer),
|
|
613
|
+
"bytes": self._buffer_bytes,
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
def _record_metric(
|
|
617
|
+
self,
|
|
618
|
+
name: str,
|
|
619
|
+
counter: float = 0,
|
|
620
|
+
gauge: float | None = None,
|
|
621
|
+
) -> None:
|
|
622
|
+
"""Record a metric if a metrics collector is available."""
|
|
623
|
+
if self._metrics is None:
|
|
624
|
+
return
|
|
625
|
+
try:
|
|
626
|
+
full_name = f"publisher_{name}"
|
|
627
|
+
if gauge is not None:
|
|
628
|
+
self._metrics.set_gauge(full_name, gauge)
|
|
629
|
+
elif counter > 0:
|
|
630
|
+
self._metrics.inc_counter(full_name, counter)
|
|
631
|
+
except Exception:
|
|
632
|
+
pass # Metrics should never break publishing
|