jararaca 0.3.11a16__py3-none-any.whl → 0.4.0a5__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.
Files changed (88) hide show
  1. README.md +121 -0
  2. jararaca/__init__.py +184 -12
  3. jararaca/__main__.py +4 -0
  4. jararaca/broker_backend/__init__.py +4 -0
  5. jararaca/broker_backend/mapper.py +4 -0
  6. jararaca/broker_backend/redis_broker_backend.py +9 -3
  7. jararaca/cli.py +272 -47
  8. jararaca/common/__init__.py +3 -0
  9. jararaca/core/__init__.py +3 -0
  10. jararaca/core/providers.py +4 -0
  11. jararaca/core/uow.py +41 -7
  12. jararaca/di.py +4 -0
  13. jararaca/files/entity.py.mako +4 -0
  14. jararaca/lifecycle.py +6 -2
  15. jararaca/messagebus/__init__.py +4 -0
  16. jararaca/messagebus/bus_message_controller.py +4 -0
  17. jararaca/messagebus/consumers/__init__.py +3 -0
  18. jararaca/messagebus/decorators.py +33 -67
  19. jararaca/messagebus/implicit_headers.py +49 -0
  20. jararaca/messagebus/interceptors/__init__.py +3 -0
  21. jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +13 -4
  22. jararaca/messagebus/interceptors/publisher_interceptor.py +4 -0
  23. jararaca/messagebus/message.py +4 -0
  24. jararaca/messagebus/publisher.py +6 -0
  25. jararaca/messagebus/worker.py +850 -383
  26. jararaca/microservice.py +110 -1
  27. jararaca/observability/constants.py +7 -0
  28. jararaca/observability/decorators.py +170 -13
  29. jararaca/observability/fastapi_exception_handler.py +37 -0
  30. jararaca/observability/hooks.py +109 -0
  31. jararaca/observability/interceptor.py +4 -0
  32. jararaca/observability/providers/__init__.py +3 -0
  33. jararaca/observability/providers/otel.py +202 -11
  34. jararaca/persistence/base.py +38 -2
  35. jararaca/persistence/exports.py +4 -0
  36. jararaca/persistence/interceptors/__init__.py +3 -0
  37. jararaca/persistence/interceptors/aiosqa_interceptor.py +86 -73
  38. jararaca/persistence/interceptors/constants.py +5 -0
  39. jararaca/persistence/interceptors/decorators.py +50 -0
  40. jararaca/persistence/session.py +3 -0
  41. jararaca/persistence/sort_filter.py +4 -0
  42. jararaca/persistence/utilities.py +50 -20
  43. jararaca/presentation/__init__.py +3 -0
  44. jararaca/presentation/decorators.py +88 -86
  45. jararaca/presentation/exceptions.py +23 -0
  46. jararaca/presentation/hooks.py +4 -0
  47. jararaca/presentation/http_microservice.py +4 -0
  48. jararaca/presentation/server.py +97 -45
  49. jararaca/presentation/websocket/__init__.py +3 -0
  50. jararaca/presentation/websocket/base_types.py +4 -0
  51. jararaca/presentation/websocket/context.py +4 -0
  52. jararaca/presentation/websocket/decorators.py +8 -41
  53. jararaca/presentation/websocket/redis.py +280 -53
  54. jararaca/presentation/websocket/types.py +4 -0
  55. jararaca/presentation/websocket/websocket_interceptor.py +46 -19
  56. jararaca/reflect/__init__.py +3 -0
  57. jararaca/reflect/controller_inspect.py +16 -10
  58. jararaca/reflect/decorators.py +238 -0
  59. jararaca/reflect/metadata.py +34 -25
  60. jararaca/rpc/__init__.py +3 -0
  61. jararaca/rpc/http/__init__.py +101 -0
  62. jararaca/rpc/http/backends/__init__.py +14 -0
  63. jararaca/rpc/http/backends/httpx.py +43 -9
  64. jararaca/rpc/http/backends/otel.py +4 -0
  65. jararaca/rpc/http/decorators.py +378 -113
  66. jararaca/rpc/http/httpx.py +3 -0
  67. jararaca/scheduler/__init__.py +3 -0
  68. jararaca/scheduler/beat_worker.py +521 -105
  69. jararaca/scheduler/decorators.py +15 -22
  70. jararaca/scheduler/types.py +4 -0
  71. jararaca/tools/app_config/__init__.py +3 -0
  72. jararaca/tools/app_config/decorators.py +7 -19
  73. jararaca/tools/app_config/interceptor.py +6 -2
  74. jararaca/tools/typescript/__init__.py +3 -0
  75. jararaca/tools/typescript/decorators.py +120 -0
  76. jararaca/tools/typescript/interface_parser.py +1074 -173
  77. jararaca/utils/__init__.py +3 -0
  78. jararaca/utils/rabbitmq_utils.py +65 -39
  79. jararaca/utils/retry.py +10 -3
  80. jararaca-0.4.0a5.dist-info/LICENSE +674 -0
  81. jararaca-0.4.0a5.dist-info/LICENSES/GPL-3.0-or-later.txt +232 -0
  82. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/METADATA +11 -7
  83. jararaca-0.4.0a5.dist-info/RECORD +88 -0
  84. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/WHEEL +1 -1
  85. pyproject.toml +131 -0
  86. jararaca-0.3.11a16.dist-info/RECORD +0 -74
  87. /jararaca-0.3.11a16.dist-info/LICENSE → /LICENSE +0 -0
  88. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/entry_points.txt +0 -0
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  import asyncio
2
6
  import inspect
3
7
  import logging
@@ -23,7 +27,14 @@ from urllib.parse import parse_qs, urlparse
23
27
  import aio_pika
24
28
  import aio_pika.abc
25
29
  import uvloop
26
- from aio_pika.exceptions import AMQPError, ChannelClosed, ChannelNotFoundEntity
30
+ from aio_pika.exceptions import (
31
+ AMQPChannelError,
32
+ AMQPConnectionError,
33
+ AMQPError,
34
+ ChannelClosed,
35
+ ChannelNotFoundEntity,
36
+ ConnectionClosed,
37
+ )
27
38
  from pydantic import BaseModel
28
39
 
29
40
  from jararaca.broker_backend import MessageBrokerBackend
@@ -43,12 +54,16 @@ from jararaca.messagebus.decorators import (
43
54
  MessageHandlerData,
44
55
  ScheduleDispatchData,
45
56
  )
57
+ from jararaca.messagebus.implicit_headers import provide_implicit_headers
46
58
  from jararaca.messagebus.message import Message, MessageOf
47
59
  from jararaca.microservice import (
48
60
  AppTransactionContext,
49
61
  MessageBusTransactionData,
50
62
  Microservice,
51
63
  SchedulerTransactionData,
64
+ ShutdownState,
65
+ provide_shutdown_state,
66
+ providing_app_type,
52
67
  )
53
68
  from jararaca.scheduler.decorators import ScheduledActionData
54
69
  from jararaca.utils.rabbitmq_utils import RabbitmqUtils
@@ -78,6 +93,9 @@ class AioPikaWorkerConfig:
78
93
  backoff_factor=2.0,
79
94
  )
80
95
  )
96
+ # Connection health monitoring settings
97
+ connection_heartbeat_interval: float = 30.0 # seconds
98
+ connection_health_check_interval: float = 10.0 # seconds
81
99
 
82
100
 
83
101
  class AioPikaMessage(MessageOf[Message]):
@@ -129,6 +147,20 @@ class MessageBusConsumer(ABC):
129
147
  """Close all resources related to the consumer"""
130
148
 
131
149
 
150
+ class _WorkerShutdownState(ShutdownState):
151
+ def __init__(self, shutdown_event: asyncio.Event):
152
+ self.shutdown_event = shutdown_event
153
+
154
+ def request_shutdown(self) -> None:
155
+ self.shutdown_event.set()
156
+
157
+ def is_shutdown_requested(self) -> bool:
158
+ return self.shutdown_event.is_set()
159
+
160
+ async def wait_for_shutdown(self) -> None:
161
+ await self.shutdown_event.wait()
162
+
163
+
132
164
  class AioPikaMicroserviceConsumer(MessageBusConsumer):
133
165
  def __init__(
134
166
  self,
@@ -146,11 +178,18 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
146
178
  self.incoming_map: dict[str, MessageHandlerData] = {}
147
179
  self.uow_context_provider = uow_context_provider
148
180
  self.shutdown_event = asyncio.Event()
181
+ self.shutdown_state = _WorkerShutdownState(self.shutdown_event)
149
182
  self.lock = asyncio.Lock()
150
183
  self.tasks: set[asyncio.Task[Any]] = set()
151
184
  self.connection: aio_pika.abc.AbstractConnection | None = None
152
185
  self.channels: dict[str, aio_pika.abc.AbstractChannel] = {}
153
186
 
187
+ # Connection resilience attributes
188
+ self.connection_healthy = False
189
+ self.connection_lock = asyncio.Lock()
190
+ self.consumer_tags: dict[str, str] = {} # Track consumer tags for cleanup
191
+ self.health_check_task: asyncio.Task[Any] | None = None
192
+
154
193
  async def _verify_infrastructure(self) -> bool:
155
194
  """
156
195
  Verify that the required RabbitMQ infrastructure (exchanges, queues) exists.
@@ -188,31 +227,48 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
188
227
  async def setup_consumer() -> None:
189
228
  # Create a channel using the context manager
190
229
  async with self.create_channel(queue_name) as channel:
191
- queue = await RabbitmqUtils.get_queue(
230
+ queue: aio_pika.abc.AbstractQueue = await RabbitmqUtils.get_queue(
192
231
  channel=channel, queue_name=queue_name
193
232
  )
194
233
 
195
- # Configure consumer right away while in the context
196
- await queue.consume(
234
+ # Configure consumer and get the consumer tag
235
+ consumer_tag = await queue.consume(
197
236
  callback=MessageHandlerCallback(
198
237
  consumer=self,
199
238
  queue_name=queue_name,
200
239
  routing_key=routing_key,
201
240
  message_handler=handler,
202
241
  ),
203
- no_ack=handler.spec.auto_ack,
242
+ # no_ack=handler.spec.auto_ack,
204
243
  )
205
244
 
245
+ # Store consumer tag for cleanup
246
+ self.consumer_tags[queue_name] = consumer_tag
247
+
206
248
  logger.info(
207
- f"Consuming message handler {queue_name} on dedicated channel"
249
+ "Consuming message handler %s on dedicated channel", queue_name
250
+ )
251
+
252
+ await self.shutdown_event.wait()
253
+
254
+ logger.warning(
255
+ "Shutdown event received, stopping consumer for %s", queue_name
208
256
  )
257
+ await queue.cancel(consumer_tag)
209
258
 
210
259
  try:
211
260
  # Setup with retry
212
261
  await retry_with_backoff(
213
262
  setup_consumer,
214
263
  retry_config=self.config.consumer_retry_config,
215
- retry_exceptions=(ChannelNotFoundEntity, ChannelClosed, AMQPError),
264
+ retry_exceptions=(
265
+ ChannelNotFoundEntity,
266
+ ChannelClosed,
267
+ AMQPError,
268
+ AMQPConnectionError,
269
+ AMQPChannelError,
270
+ ConnectionClosed,
271
+ ),
216
272
  )
217
273
  return True
218
274
  except Exception as e:
@@ -238,8 +294,8 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
238
294
  channel=channel, queue_name=queue_name
239
295
  )
240
296
 
241
- # Configure consumer right away while in the context
242
- await queue.consume(
297
+ # Configure consumer and get the consumer tag
298
+ consumer_tag = await queue.consume(
243
299
  callback=ScheduledMessageHandlerCallback(
244
300
  consumer=self,
245
301
  queue_name=queue_name,
@@ -249,14 +305,31 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
249
305
  no_ack=True,
250
306
  )
251
307
 
252
- logger.info(f"Consuming scheduler {queue_name} on dedicated channel")
308
+ # Store consumer tag for cleanup
309
+ self.consumer_tags[queue_name] = consumer_tag
310
+
311
+ logger.debug("Consuming scheduler %s on dedicated channel", queue_name)
312
+
313
+ await self.shutdown_event.wait()
314
+
315
+ logger.warning(
316
+ "Shutdown event received, stopping consumer for %s", queue_name
317
+ )
318
+ await queue.cancel(consumer_tag)
253
319
 
254
320
  try:
255
321
  # Setup with retry
256
322
  await retry_with_backoff(
257
323
  setup_consumer,
258
324
  retry_config=self.config.consumer_retry_config,
259
- retry_exceptions=(ChannelNotFoundEntity, ChannelClosed, AMQPError),
325
+ retry_exceptions=(
326
+ ChannelNotFoundEntity,
327
+ ChannelClosed,
328
+ AMQPError,
329
+ AMQPConnectionError,
330
+ AMQPChannelError,
331
+ ConnectionClosed,
332
+ ),
260
333
  )
261
334
  return True
262
335
  except Exception as e:
@@ -269,160 +342,195 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
269
342
  """
270
343
  Main consume method that sets up all message handlers and scheduled actions with retry mechanisms.
271
344
  """
272
- # Verify infrastructure with retry
273
- infra_check_success = await retry_with_backoff(
274
- self._verify_infrastructure,
275
- retry_config=self.config.connection_retry_config,
276
- retry_exceptions=(Exception,),
277
- )
278
-
279
- if not infra_check_success:
280
- logger.critical("Failed to verify RabbitMQ infrastructure. Shutting down.")
281
- self.shutdown_event.set()
282
- return
283
-
284
- async def wait_for(
285
- type: str, name: str, coroutine: Awaitable[bool]
286
- ) -> tuple[str, str, bool]:
287
- return type, name, await coroutine
345
+ # Establish initial connection
346
+ try:
347
+ async with self.connect() as connection:
348
+ self.connection_healthy = True
288
349
 
289
- tasks: set[asyncio.Task[tuple[str, str, bool]]] = set()
350
+ # Start connection health monitoring
351
+ self.health_check_task = asyncio.create_task(
352
+ self._monitor_connection_health(), name="ConnectionHealthMonitor"
353
+ )
290
354
 
291
- # Setup message handlers
292
- for handler in self.message_handler_set:
293
- queue_name = f"{handler.message_type.MESSAGE_TOPIC}.{handler.instance_callable.__module__}.{handler.instance_callable.__qualname__}"
294
- self.incoming_map[queue_name] = handler
355
+ # Verify infrastructure with retry
356
+ infra_check_success = await retry_with_backoff(
357
+ self._verify_infrastructure,
358
+ retry_config=self.config.connection_retry_config,
359
+ retry_exceptions=(Exception,),
360
+ )
295
361
 
296
- tasks.add(
297
- task := asyncio.create_task(
298
- wait_for(
299
- "message_handler",
300
- queue_name,
301
- self._setup_message_handler_consumer(handler),
362
+ if not infra_check_success:
363
+ logger.critical(
364
+ "Failed to verify RabbitMQ infrastructure. Shutting down."
302
365
  )
303
- )
304
- )
305
- # task.add_done_callback(tasks.discard)
306
- # success = await self._setup_message_handler_consumer(handler)
307
- # if not success:
308
- # logger.warning(
309
- # f"Failed to set up consumer for {queue_name}, will not process messages from this queue"
310
- # )
311
-
312
- # Setup scheduled actions
313
- for scheduled_action in self.scheduled_actions:
314
-
315
- queue_name = f"{scheduled_action.callable.__module__}.{scheduled_action.callable.__qualname__}"
316
- tasks.add(
317
- task := asyncio.create_task(
318
- wait_for(
319
- "scheduled_action",
320
- queue_name,
321
- self._setup_scheduled_action_consumer(scheduled_action),
366
+ self.shutdown_event.set()
367
+ return
368
+
369
+ async def wait_for(
370
+ type: str, name: str, coroutine: Awaitable[bool]
371
+ ) -> tuple[str, str, bool]:
372
+ return type, name, await coroutine
373
+
374
+ tasks: set[asyncio.Task[tuple[str, str, bool]]] = set()
375
+
376
+ # Setup message handlers
377
+ for handler in self.message_handler_set:
378
+ queue_name = f"{handler.message_type.MESSAGE_TOPIC}.{handler.instance_callable.__module__}.{handler.instance_callable.__qualname__}"
379
+ self.incoming_map[queue_name] = handler
380
+
381
+ tasks.add(
382
+ task := asyncio.create_task(
383
+ wait_for(
384
+ "message_handler",
385
+ queue_name,
386
+ self._setup_message_handler_consumer(handler),
387
+ ),
388
+ name=f"MessageHandler-{queue_name}-setup-consumer",
389
+ )
322
390
  )
323
- )
324
- )
325
- # task.add_done_callback(tasks.discard)
326
-
327
- # success = await self._setup_scheduled_action_consumer(scheduled_action)
328
- # if not success:
329
- # queue_name = f"{scheduled_action.callable.__module__}.{scheduled_action.callable.__qualname__}"
330
- # logger.warning(
331
- # f"Failed to set up consumer for scheduled action {queue_name}, will not process scheduled tasks from this queue"
332
- # )
333
-
334
- async def handle_task_results() -> None:
335
- for task in asyncio.as_completed(tasks):
336
- type, name, success = await task
337
- if success:
338
- logger.info(f"Successfully set up {type} consumer for {name}")
339
- else:
340
- logger.warning(
341
- f"Failed to set up {type} consumer for {name}, will not process messages from this queue"
391
+
392
+ # Setup scheduled actions
393
+ for scheduled_action in self.scheduled_actions:
394
+ queue_name = f"{scheduled_action.callable.__module__}.{scheduled_action.callable.__qualname__}"
395
+ tasks.add(
396
+ task := asyncio.create_task(
397
+ wait_for(
398
+ "scheduled_action",
399
+ queue_name,
400
+ self._setup_scheduled_action_consumer(scheduled_action),
401
+ ),
402
+ name=f"ScheduledAction-{queue_name}-setup-consumer",
403
+ )
342
404
  )
343
405
 
344
- handle_task_results_task = asyncio.create_task(handle_task_results())
406
+ async def handle_task_results() -> None:
407
+ for task in asyncio.as_completed(tasks):
408
+ type, name, success = await task
409
+ if success:
410
+ logger.debug(
411
+ "Successfully set up %s consumer for %s", type, name
412
+ )
413
+ else:
414
+ logger.warning(
415
+ "Failed to set up %s consumer for %s, will not process messages from this queue",
416
+ type,
417
+ name,
418
+ )
345
419
 
346
- # Wait for shutdown signal
347
- await self.shutdown_event.wait()
348
- logger.info("Shutdown event received, stopping consumers")
349
- handle_task_results_task.cancel()
350
- with suppress(asyncio.CancelledError):
351
- await handle_task_results_task
352
- for task in tasks:
353
- if not task.done():
354
- task.cancel()
355
- with suppress(asyncio.CancelledError):
356
- await task
357
- logger.info("Worker shutting down")
420
+ handle_task_results_task = asyncio.create_task(
421
+ handle_task_results(), name="HandleSetupTaskResults"
422
+ )
358
423
 
359
- # Wait for all tasks to complete
360
- await self.wait_all_tasks_done()
424
+ # Wait for shutdown signal
425
+ await self.shutdown_event.wait()
426
+ logger.debug("Shutdown event received, stopping consumers")
361
427
 
362
- # Close all channels and the connection
363
- await self.close_channels_and_connection()
428
+ await self.cancel_queue_consumers()
364
429
 
365
- async def wait_all_tasks_done(self) -> None:
366
- if not self.tasks:
367
- return
430
+ # Cancel health monitoring
431
+ if self.health_check_task:
432
+ self.health_check_task.cancel()
433
+ with suppress(asyncio.CancelledError):
434
+ await self.health_check_task
368
435
 
369
- logger.info(f"Waiting for {len(self.tasks)} in-flight tasks to complete")
370
- async with self.lock:
371
- # Use gather with return_exceptions=True to ensure all tasks are awaited
372
- # even if some raise exceptions
373
- results = await asyncio.gather(*self.tasks, return_exceptions=True)
436
+ handle_task_results_task.cancel()
437
+ with suppress(asyncio.CancelledError):
438
+ await handle_task_results_task
439
+ for task in tasks:
440
+ if not task.done():
441
+ task.cancel()
442
+ with suppress(asyncio.CancelledError):
443
+ await task
444
+ logger.debug("Worker shutting down")
445
+ # Wait for all tasks to complete
446
+ await self.wait_all_tasks_done()
447
+
448
+ # Close all channels and the connection
449
+ await self.close_channels_and_connection()
374
450
 
375
- # Log any exceptions that occurred
376
- for result in results:
377
- if isinstance(result, Exception):
378
- logger.error(f"Task raised an exception during shutdown: {result}")
451
+ except Exception as e:
452
+ logger.critical("Failed to establish initial connection to RabbitMQ: %s", e)
453
+ # Re-raise the exception so it can be caught by the caller
454
+ raise
379
455
 
380
- async def close_channels_and_connection(self) -> None:
381
- """Close all channels and then the connection"""
382
- # Close all channels
383
- channel_close_tasks = []
456
+ async def cancel_queue_consumers(self) -> None:
457
+ """
458
+ Cancel all active queue consumers.
459
+ """
460
+ logger.debug("Cancelling all active queue consumers...")
384
461
  for queue_name, channel in self.channels.items():
385
462
  try:
386
463
  if not channel.is_closed:
387
- logger.info(f"Closing channel for queue {queue_name}")
388
- channel_close_tasks.append(channel.close())
389
- else:
390
- logger.info(f"Channel for queue {queue_name} already closed")
464
+ # Cancel consumer if we have its tag
465
+ if queue_name in self.consumer_tags:
466
+ try:
467
+ queue = await channel.get_queue(queue_name, ensure=False)
468
+ if queue:
469
+ await queue.cancel(self.consumer_tags[queue_name])
470
+ except Exception as cancel_error:
471
+ logger.warning(
472
+ "Error cancelling consumer for %s: %s",
473
+ queue_name,
474
+ cancel_error,
475
+ )
476
+ del self.consumer_tags[queue_name]
391
477
  except Exception as e:
392
- logger.error(
393
- f"Error preparing to close channel for queue {queue_name}: {e}"
394
- )
478
+ logger.warning("Error cancelling consumer for %s: %s", queue_name, e)
395
479
 
396
- # Wait for all channels to close (if any)
397
- if channel_close_tasks:
398
- try:
399
- await asyncio.gather(*channel_close_tasks, return_exceptions=True)
400
- except Exception as e:
401
- logger.error(f"Error during channel closures: {e}")
480
+ async def wait_all_tasks_done(self) -> None:
481
+ if not self.tasks:
482
+ return
402
483
 
403
- # Clear channels dictionary
404
- self.channels.clear()
484
+ logger.warning(
485
+ "Waiting for (%s) in-flight tasks to complete: %s",
486
+ len(self.tasks),
487
+ ", ".join((task.get_name()) for task in self.tasks),
488
+ )
489
+ # async with self.lock:
490
+ # Use gather with return_exceptions=True to ensure all tasks are awaited
491
+ # even if some raise exceptions
492
+ # results = await asyncio.gather(*self.tasks, return_exceptions=True)
493
+ pending_tasks = [task for task in self.tasks if not task.done()]
494
+ while len(pending_tasks) > 0:
495
+ if not pending_tasks:
496
+ break
497
+ await asyncio.wait(pending_tasks, return_when=asyncio.FIRST_COMPLETED)
498
+
499
+ pending_tasks = [task for task in pending_tasks if not task.done()]
500
+ if len(pending_tasks) > 0:
501
+ logger.warning(
502
+ "Waiting for (%s) in-flight tasks to complete: %s",
503
+ len(pending_tasks),
504
+ ", ".join((task.get_name()) for task in pending_tasks),
505
+ )
506
+ else:
507
+ logger.warning("All in-flight tasks have completed.")
508
+ # Log any exceptions that occurred
509
+ # for result in results:
510
+ # if isinstance(result, Exception):
511
+ # logger.error("Task raised an exception during shutdown: %s", result)
405
512
 
406
- # Close the connection
407
- if self.connection:
408
- try:
409
- if not self.connection.is_closed:
410
- logger.info("Closing RabbitMQ connection")
411
- await self.connection.close()
412
- else:
413
- logger.info("RabbitMQ connection already closed")
414
- except Exception as e:
415
- logger.error(f"Error closing RabbitMQ connection: {e}")
416
- self.connection = None
513
+ async def close_channels_and_connection(self) -> None:
514
+ """Close all channels and then the connection"""
515
+ logger.warning("Closing channels and connection...")
516
+ await self._cleanup_connection()
417
517
 
418
518
  def shutdown(self) -> None:
419
519
  """Signal for shutdown"""
420
- logger.info("Initiating graceful shutdown")
520
+ logger.warning("Initiating graceful shutdown")
421
521
  self.shutdown_event.set()
422
522
 
423
523
  async def close(self) -> None:
424
524
  """Implement MessageBusConsumer.close for cleanup"""
525
+ logger.warning("Closing consumer...")
425
526
  self.shutdown()
527
+
528
+ # Cancel health monitoring
529
+ if self.health_check_task:
530
+ self.health_check_task.cancel()
531
+ with suppress(asyncio.CancelledError):
532
+ await self.health_check_task
533
+
426
534
  await self.wait_all_tasks_done()
427
535
  await self.close_channels_and_connection()
428
536
 
@@ -432,25 +540,45 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
432
540
  This helps with error handling when a channel might have been closed.
433
541
  """
434
542
  if queue_name not in self.channels:
435
- logger.warning(f"No channel found for queue {queue_name}")
543
+ logger.warning("No channel found for queue %s", queue_name)
436
544
  return None
437
545
 
438
546
  try:
439
547
  channel = self.channels[queue_name]
440
548
  if channel.is_closed:
441
- logger.warning(f"Channel for queue {queue_name} is closed")
442
- # Attempt to recreate the channel if needed
443
- if self.connection and not self.connection.is_closed:
444
- logger.info(f"Creating new channel for {queue_name}")
445
- self.channels[queue_name] = await self.connection.channel()
446
- await self.channels[queue_name].set_qos(
447
- prefetch_count=self.config.prefetch_count
448
- )
449
- return self.channels[queue_name]
450
- return None
549
+ logger.warning("Channel for queue %s is closed", queue_name)
550
+ # Remove the closed channel
551
+ del self.channels[queue_name]
552
+
553
+ # Attempt to recreate the channel if connection is healthy
554
+ if (
555
+ self.connection
556
+ and not self.connection.is_closed
557
+ and self.connection_healthy
558
+ ):
559
+ try:
560
+ logger.debug("Creating new channel for %s", queue_name)
561
+ self.channels[queue_name] = await self.connection.channel()
562
+ await self.channels[queue_name].set_qos(
563
+ prefetch_count=self.config.prefetch_count
564
+ )
565
+ return self.channels[queue_name]
566
+ except Exception as e:
567
+ logger.error(
568
+ "Failed to recreate channel for %s: %s", queue_name, e
569
+ )
570
+ # Trigger shutdown if channel creation fails
571
+ self._trigger_shutdown()
572
+ return None
573
+ else:
574
+ # Connection is not healthy, trigger shutdown
575
+ self._trigger_shutdown()
576
+ return None
451
577
  return channel
452
578
  except Exception as e:
453
- logger.error(f"Error accessing channel for queue {queue_name}: {e}")
579
+ logger.error("Error accessing channel for queue %s: %s", queue_name, e)
580
+ # Trigger shutdown on any channel access error
581
+ self._trigger_shutdown()
454
582
  return None
455
583
 
456
584
  async def _establish_channel(self, queue_name: str) -> aio_pika.abc.AbstractChannel:
@@ -459,14 +587,14 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
459
587
  """
460
588
  if self.connection is None or self.connection.is_closed:
461
589
  logger.warning(
462
- f"Cannot create channel for {queue_name}: connection is not available"
590
+ "Cannot create channel for %s: connection is not available", queue_name
463
591
  )
464
592
  raise RuntimeError("Connection is not available")
465
593
 
466
- logger.debug(f"Creating channel for queue {queue_name}")
594
+ logger.debug("Creating channel for queue %s", queue_name)
467
595
  channel = await self.connection.channel()
468
596
  await channel.set_qos(prefetch_count=self.config.prefetch_count)
469
- logger.debug(f"Created channel for queue {queue_name}")
597
+ logger.debug("Created channel for queue %s", queue_name)
470
598
  return channel
471
599
 
472
600
  @asynccontextmanager
@@ -483,15 +611,15 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
483
611
  fn=lambda: self._establish_channel(queue_name),
484
612
  retry_config=self.config.consumer_retry_config,
485
613
  retry_exceptions=(
486
- aio_pika.exceptions.AMQPConnectionError,
487
- aio_pika.exceptions.AMQPChannelError,
614
+ AMQPConnectionError,
615
+ AMQPChannelError,
488
616
  ConnectionError,
489
617
  ),
490
618
  )
491
619
 
492
620
  # Save in the channels dict for tracking
493
621
  self.channels[queue_name] = channel
494
- logger.debug(f"Created new channel for queue {queue_name}")
622
+ logger.debug("Created new channel for queue %s", queue_name)
495
623
 
496
624
  try:
497
625
  yield channel
@@ -501,7 +629,7 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
501
629
  pass
502
630
  except aio_pika.exceptions.AMQPError as e:
503
631
  logger.error(
504
- f"Error creating channel for queue {queue_name} after retries: {e}"
632
+ "Error creating channel for queue %s after retries: %s", queue_name, e
505
633
  )
506
634
  raise
507
635
 
@@ -510,12 +638,15 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
510
638
  Creates a new RabbitMQ connection with retry logic.
511
639
  """
512
640
  try:
513
- logger.info("Establishing connection to RabbitMQ")
514
- connection = await aio_pika.connect(self.config.url)
515
- logger.info("Connected to RabbitMQ successfully")
641
+ logger.debug("Establishing connection to RabbitMQ")
642
+ connection = await aio_pika.connect(
643
+ self.config.url,
644
+ heartbeat=self.config.connection_heartbeat_interval,
645
+ )
646
+ logger.debug("Connected to RabbitMQ successfully")
516
647
  return connection
517
648
  except Exception as e:
518
- logger.error(f"Failed to connect to RabbitMQ: {e}")
649
+ logger.error("Failed to connect to RabbitMQ: %s", e)
519
650
  raise
520
651
 
521
652
  @asynccontextmanager
@@ -538,7 +669,7 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
538
669
  self._establish_connection,
539
670
  retry_config=self.config.connection_retry_config,
540
671
  retry_exceptions=(
541
- aio_pika.exceptions.AMQPConnectionError,
672
+ AMQPConnectionError,
542
673
  ConnectionError,
543
674
  OSError,
544
675
  TimeoutError,
@@ -552,14 +683,15 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
552
683
  pass
553
684
  except Exception as e:
554
685
  logger.error(
555
- f"Failed to establish connection to RabbitMQ after retries: {e}"
686
+ "Failed to establish connection to RabbitMQ after retries: %s", e
556
687
  )
557
688
  if self.connection:
558
689
  try:
559
690
  await self.connection.close()
560
691
  except Exception as close_error:
561
692
  logger.error(
562
- f"Error closing connection after connect failure: {close_error}"
693
+ "Error closing connection after connect failure: %s",
694
+ close_error,
563
695
  )
564
696
  self.connection = None
565
697
  raise
@@ -572,22 +704,153 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
572
704
  Get a channel for a specific queue as a context manager.
573
705
  This is safer than using get_channel directly as it ensures proper error handling.
574
706
  """
575
- channel = await self.get_channel(queue_name)
576
- if channel is None:
577
- if self.connection and not self.connection.is_closed:
578
- # Try to create a new channel
579
- async with self.create_channel(queue_name) as new_channel:
580
- yield new_channel
581
- else:
582
- raise RuntimeError(
583
- f"Cannot get channel for queue {queue_name}: no connection available"
584
- )
585
- else:
707
+ max_retries = 3
708
+ retry_delay = 1.0
709
+
710
+ for attempt in range(max_retries):
586
711
  try:
587
- yield channel
588
- finally:
589
- # We don't close the channel here as it's managed by the consumer
590
- pass
712
+ channel = await self.get_channel(queue_name)
713
+ if channel is not None:
714
+ try:
715
+ yield channel
716
+ return
717
+ finally:
718
+ # We don't close the channel here as it's managed by the consumer
719
+ pass
720
+
721
+ # No channel available, check connection state
722
+ if (
723
+ self.connection
724
+ and not self.connection.is_closed
725
+ and self.connection_healthy
726
+ ):
727
+ # Try to create a new channel
728
+ async with self.create_channel(queue_name) as new_channel:
729
+ yield new_channel
730
+ return
731
+ else:
732
+ # Connection is not healthy, trigger shutdown
733
+ logger.error(
734
+ "Connection not healthy while getting channel for %s, triggering shutdown",
735
+ queue_name,
736
+ )
737
+ self._trigger_shutdown()
738
+ raise RuntimeError(
739
+ f"Cannot get channel for queue {queue_name}: connection is not healthy"
740
+ )
741
+
742
+ except Exception as e:
743
+ if attempt < max_retries - 1:
744
+ logger.warning(
745
+ "Error getting channel for %s, retrying: %s", queue_name, e
746
+ )
747
+ await asyncio.sleep(retry_delay)
748
+ retry_delay *= 2
749
+ else:
750
+ logger.error(
751
+ "Failed to get channel for %s after %s attempts: %s",
752
+ queue_name,
753
+ max_retries,
754
+ e,
755
+ )
756
+ raise
757
+
758
+ async def _monitor_connection_health(self) -> None:
759
+ """
760
+ Monitor connection health and trigger shutdown if connection is lost.
761
+ This runs as a background task.
762
+ """
763
+ while not self.shutdown_event.is_set():
764
+ try:
765
+ await asyncio.sleep(self.config.connection_health_check_interval)
766
+
767
+ if self.shutdown_event.is_set():
768
+ break
769
+
770
+ # Check connection health
771
+ if not await self._is_connection_healthy():
772
+ logger.error(
773
+ "Connection health check failed, initiating worker shutdown"
774
+ )
775
+ self.shutdown()
776
+ break
777
+
778
+ except asyncio.CancelledError:
779
+ logger.debug("Connection health monitoring cancelled")
780
+ break
781
+ except Exception as e:
782
+ logger.error("Error in connection health monitoring: %s", e)
783
+ await asyncio.sleep(5) # Wait before retrying
784
+
785
+ async def _is_connection_healthy(self) -> bool:
786
+ """
787
+ Check if the connection is healthy.
788
+ """
789
+ try:
790
+ if self.connection is None or self.connection.is_closed:
791
+ return False
792
+
793
+ # Try to create a temporary channel to test connection
794
+ async with self.connection.channel() as test_channel:
795
+ # If we can create a channel, connection is healthy
796
+ return True
797
+
798
+ except Exception as e:
799
+ logger.debug("Connection health check failed: %s", e)
800
+ return False
801
+
802
+ def _trigger_shutdown(self) -> None:
803
+ """
804
+ Trigger worker shutdown due to connection loss.
805
+ """
806
+ if not self.shutdown_event.is_set():
807
+ logger.error("Connection lost, initiating worker shutdown")
808
+ self.connection_healthy = False
809
+ self.shutdown()
810
+
811
+ async def _cleanup_connection(self) -> None:
812
+ """
813
+ Clean up existing connection and channels.
814
+ """
815
+ # Cancel existing consumers
816
+ for queue_name, channel in self.channels.items():
817
+ try:
818
+ if not channel.is_closed:
819
+ # Cancel consumer if we have its tag
820
+ if queue_name in self.consumer_tags:
821
+ try:
822
+ queue = await channel.get_queue(queue_name, ensure=False)
823
+ if queue:
824
+ await queue.cancel(self.consumer_tags[queue_name])
825
+ except Exception as cancel_error:
826
+ logger.warning(
827
+ "Error cancelling consumer for %s: %s",
828
+ queue_name,
829
+ cancel_error,
830
+ )
831
+ del self.consumer_tags[queue_name]
832
+ except Exception as e:
833
+ logger.warning("Error cancelling consumer for %s: %s", queue_name, e)
834
+
835
+ # Close channels
836
+ for queue_name, channel in self.channels.items():
837
+ try:
838
+ if not channel.is_closed:
839
+ await channel.close()
840
+ except Exception as e:
841
+ logger.warning("Error closing channel for %s: %s", queue_name, e)
842
+
843
+ self.channels.clear()
844
+
845
+ # Close connection
846
+ if self.connection and not self.connection.is_closed:
847
+ try:
848
+ await self.connection.close()
849
+ except Exception as e:
850
+ logger.warning("Error closing connection: %s", e)
851
+
852
+ self.connection = None
853
+ self.connection_healthy = False
591
854
 
592
855
 
593
856
  def create_message_bus(
@@ -632,6 +895,10 @@ def create_message_bus(
632
895
  max_retries=30, initial_delay=5, max_delay=60.0, backoff_factor=3.0
633
896
  )
634
897
 
898
+ # Parse heartbeat and health check intervals
899
+ connection_heartbeat_interval = 30.0
900
+ connection_health_check_interval = 10.0
901
+
635
902
  # Connection retry config parameters
636
903
  if (
637
904
  "connection_retry_max" in query_params
@@ -698,12 +965,31 @@ def create_message_bus(
698
965
  except ValueError:
699
966
  pass
700
967
 
968
+ # Heartbeat and health check intervals
969
+ if "connection_heartbeat_interval" in query_params:
970
+ try:
971
+ connection_heartbeat_interval = float(
972
+ query_params["connection_heartbeat_interval"][0]
973
+ )
974
+ except ValueError:
975
+ pass
976
+
977
+ if "connection_health_check_interval" in query_params:
978
+ try:
979
+ connection_health_check_interval = float(
980
+ query_params["connection_health_check_interval"][0]
981
+ )
982
+ except ValueError:
983
+ pass
984
+
701
985
  config = AioPikaWorkerConfig(
702
986
  url=broker_url,
703
987
  exchange=exchange,
704
988
  prefetch_count=prefetch_count,
705
989
  connection_retry_config=connection_retry_config,
706
990
  consumer_retry_config=consumer_retry_config,
991
+ connection_heartbeat_interval=connection_heartbeat_interval,
992
+ connection_health_check_interval=connection_health_check_interval,
707
993
  )
708
994
 
709
995
  return AioPikaMicroserviceConsumer(
@@ -737,37 +1023,59 @@ class ScheduledMessageHandlerCallback:
737
1023
  ) -> None:
738
1024
 
739
1025
  if self.consumer.shutdown_event.is_set():
740
- logger.info(
741
- f"Shutdown in progress. Requeuing scheduled message for {self.queue_name}"
1026
+ logger.debug(
1027
+ "Shutdown in progress. Requeuing scheduled message for %s",
1028
+ self.queue_name,
742
1029
  )
743
1030
  try:
744
1031
  # Use channel context for requeuing
745
- async with self.consumer.get_channel_ctx(self.queue_name):
746
- await aio_pika_message.reject(requeue=True)
1032
+ await aio_pika_message.reject(requeue=True)
747
1033
  except RuntimeError:
748
1034
  logger.warning(
749
- f"Could not requeue scheduled message during shutdown - channel not available"
1035
+ "Could not requeue scheduled message during shutdown - channel not available"
750
1036
  )
751
1037
  except Exception as e:
752
1038
  logger.error(
753
- f"Failed to requeue scheduled message during shutdown: {e}"
1039
+ "Failed to requeue scheduled message during shutdown: %s", e
754
1040
  )
755
1041
  return
756
1042
 
1043
+ # Check if connection is healthy before processing
1044
+ if not self.consumer.connection_healthy:
1045
+ logger.warning(
1046
+ "Connection not healthy, requeuing scheduled message for %s",
1047
+ self.queue_name,
1048
+ )
1049
+ try:
1050
+ if not self.consumer.connection_healthy:
1051
+ # Still not healthy, requeue the message
1052
+ async with self.consumer.get_channel_ctx(self.queue_name):
1053
+ await aio_pika_message.reject(requeue=True)
1054
+ return
1055
+ except Exception as e:
1056
+ logger.error(
1057
+ "Failed to requeue scheduled message due to connection issues: %s",
1058
+ e,
1059
+ )
1060
+ return
1061
+
757
1062
  async with self.consumer.lock:
758
- task = asyncio.create_task(self.handle_message(aio_pika_message))
1063
+ task = asyncio.create_task(
1064
+ self.handle_message(aio_pika_message),
1065
+ name=f"ScheduledAction-{self.queue_name}-handle-message-{aio_pika_message.message_id}",
1066
+ )
759
1067
  self.consumer.tasks.add(task)
760
1068
  task.add_done_callback(self.handle_message_consume_done)
761
1069
 
762
1070
  def handle_message_consume_done(self, task: asyncio.Task[Any]) -> None:
763
1071
  self.consumer.tasks.discard(task)
764
1072
  if task.cancelled():
765
- logger.warning(f"Scheduled task for {self.queue_name} was cancelled")
1073
+ logger.warning("Scheduled task for %s was cancelled", self.queue_name)
766
1074
  return
767
1075
 
768
1076
  if (error := task.exception()) is not None:
769
1077
  logger.exception(
770
- f"Error processing scheduled action {self.queue_name}", exc_info=error
1078
+ "Error processing scheduled action %s", self.queue_name, exc_info=error
771
1079
  )
772
1080
 
773
1081
  async def handle_message(
@@ -775,7 +1083,9 @@ class ScheduledMessageHandlerCallback:
775
1083
  ) -> None:
776
1084
 
777
1085
  if self.consumer.shutdown_event.is_set():
778
- logger.info(f"Shutdown event set. Requeuing message for {self.queue_name}")
1086
+ logger.debug(
1087
+ "Shutdown event set. Requeuing message for %s", self.queue_name
1088
+ )
779
1089
  try:
780
1090
  # Use channel context for requeuing
781
1091
  async with self.consumer.get_channel_ctx(self.queue_name):
@@ -783,10 +1093,27 @@ class ScheduledMessageHandlerCallback:
783
1093
  return
784
1094
  except RuntimeError:
785
1095
  logger.warning(
786
- f"Could not requeue message during shutdown - channel not available"
1096
+ "Could not requeue message during shutdown - channel not available"
787
1097
  )
788
1098
  except Exception as e:
789
- logger.error(f"Failed to requeue message during shutdown: {e}")
1099
+ logger.error("Failed to requeue message during shutdown: %s", e)
1100
+ return
1101
+
1102
+ # Check connection health before processing
1103
+ if not self.consumer.connection_healthy:
1104
+ logger.warning(
1105
+ "Connection not healthy, requeuing scheduled message for %s",
1106
+ self.queue_name,
1107
+ )
1108
+ try:
1109
+ async with self.consumer.get_channel_ctx(self.queue_name):
1110
+ await aio_pika_message.reject(requeue=True)
1111
+ return
1112
+ except Exception as e:
1113
+ logger.error(
1114
+ "Failed to requeue scheduled message due to connection issues: %s",
1115
+ e,
1116
+ )
790
1117
  return
791
1118
 
792
1119
  sig = inspect.signature(self.scheduled_action.callable)
@@ -797,7 +1124,8 @@ class ScheduledMessageHandlerCallback:
797
1124
  self.scheduled_action,
798
1125
  (ScheduleDispatchData(int(aio_pika_message.body.decode("utf-8"))),),
799
1126
  {},
800
- )
1127
+ ),
1128
+ name=f"ScheduledAction-{self.queue_name}-handle-message-{aio_pika_message.message_id}",
801
1129
  )
802
1130
 
803
1131
  elif len(sig.parameters) == 0:
@@ -806,7 +1134,8 @@ class ScheduledMessageHandlerCallback:
806
1134
  self.scheduled_action,
807
1135
  (),
808
1136
  {},
809
- )
1137
+ ),
1138
+ name=f"ScheduledAction-{self.queue_name}-handle-message-{aio_pika_message.message_id}",
810
1139
  )
811
1140
  else:
812
1141
  logger.warning(
@@ -823,7 +1152,7 @@ class ScheduledMessageHandlerCallback:
823
1152
  except Exception as e:
824
1153
 
825
1154
  logger.exception(
826
- f"Error processing scheduled action {self.queue_name}: {e}"
1155
+ "Error processing scheduled action %s: %s", self.queue_name, e
827
1156
  )
828
1157
 
829
1158
  async def run_with_context(
@@ -832,18 +1161,22 @@ class ScheduledMessageHandlerCallback:
832
1161
  args: tuple[Any, ...],
833
1162
  kwargs: dict[str, Any],
834
1163
  ) -> None:
835
- async with self.consumer.uow_context_provider(
836
- AppTransactionContext(
837
- controller_member_reflect=scheduled_action.controller_member,
838
- transaction_data=SchedulerTransactionData(
839
- scheduled_to=datetime.now(UTC),
840
- cron_expression=scheduled_action.spec.cron,
841
- triggered_at=datetime.now(UTC),
842
- ),
843
- )
844
- ):
845
1164
 
846
- await scheduled_action.callable(*args, **kwargs)
1165
+ with provide_shutdown_state(self.consumer.shutdown_state):
1166
+ async with self.consumer.uow_context_provider(
1167
+ AppTransactionContext(
1168
+ controller_member_reflect=scheduled_action.controller_member,
1169
+ transaction_data=SchedulerTransactionData(
1170
+ task_name=scheduled_action.spec.name
1171
+ or scheduled_action.callable.__qualname__,
1172
+ scheduled_to=datetime.now(UTC),
1173
+ cron_expression=scheduled_action.spec.cron,
1174
+ triggered_at=datetime.now(UTC),
1175
+ ),
1176
+ )
1177
+ ):
1178
+
1179
+ await scheduled_action.callable(*args, **kwargs)
847
1180
 
848
1181
 
849
1182
  class MessageHandlerCallback:
@@ -865,8 +1198,8 @@ class MessageHandlerCallback:
865
1198
  self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
866
1199
  ) -> None:
867
1200
  if self.consumer.shutdown_event.is_set():
868
- logger.info(
869
- f"Shutdown in progress. Requeuing message for {self.queue_name}"
1201
+ logger.debug(
1202
+ "Shutdown in progress. Requeuing message for %s", self.queue_name
870
1203
  )
871
1204
  try:
872
1205
  # Use channel context for requeuing
@@ -874,26 +1207,46 @@ class MessageHandlerCallback:
874
1207
  await aio_pika_message.reject(requeue=True)
875
1208
  except RuntimeError:
876
1209
  logger.warning(
877
- f"Could not requeue message during shutdown - channel not available"
1210
+ "Could not requeue message during shutdown - channel not available"
878
1211
  )
879
1212
  except Exception as e:
880
- logger.error(f"Failed to requeue message during shutdown: {e}")
1213
+ logger.error("Failed to requeue message during shutdown: %s", e)
881
1214
  return
882
1215
 
1216
+ # Check if connection is healthy before processing
1217
+ if not self.consumer.connection_healthy:
1218
+ logger.warning(
1219
+ "Connection not healthy, requeuing message for %s", self.queue_name
1220
+ )
1221
+ try:
1222
+ if not self.consumer.connection_healthy:
1223
+ # Still not healthy, requeue the message
1224
+ async with self.consumer.get_channel_ctx(self.queue_name):
1225
+ await aio_pika_message.reject(requeue=True)
1226
+ return
1227
+ except Exception as e:
1228
+ logger.error(
1229
+ "Failed to requeue message due to connection issues: %s", e
1230
+ )
1231
+ return
1232
+
883
1233
  async with self.consumer.lock:
884
- task = asyncio.create_task(self.handle_message(aio_pika_message))
1234
+ task = asyncio.create_task(
1235
+ self.handle_message(aio_pika_message),
1236
+ name=f"MessageHandler-{self.queue_name}-handle-message-{aio_pika_message.message_id}",
1237
+ )
885
1238
  self.consumer.tasks.add(task)
886
1239
  task.add_done_callback(self.handle_message_consume_done)
887
1240
 
888
1241
  def handle_message_consume_done(self, task: asyncio.Task[Any]) -> None:
889
1242
  self.consumer.tasks.discard(task)
890
1243
  if task.cancelled():
891
- logger.warning(f"Task for queue {self.queue_name} was cancelled")
1244
+ logger.warning("Task for queue %s was cancelled", self.queue_name)
892
1245
  return
893
1246
 
894
1247
  if (error := task.exception()) is not None:
895
1248
  logger.exception(
896
- f"Error processing message for queue {self.queue_name}", exc_info=error
1249
+ "Error processing message for queue %s", self.queue_name, exc_info=error
897
1250
  )
898
1251
 
899
1252
  async def __call__(
@@ -919,19 +1272,11 @@ class MessageHandlerCallback:
919
1272
  """
920
1273
  message_id = aio_pika_message.message_id or str(uuid.uuid4())
921
1274
 
922
- # If auto_ack is enabled, we cannot retry the message through RabbitMQ reject mechanism
923
- if self.message_handler.spec.auto_ack:
924
- if requeue:
925
- logger.warning(
926
- f"Message {message_id} ({self.queue_name}) cannot be requeued because auto_ack is enabled"
927
- )
928
- return
929
-
930
1275
  try:
931
1276
  # Check if we should retry with backoff
932
1277
  if (
933
1278
  not requeue
934
- and self.message_handler.spec.requeue_on_exception
1279
+ and self.message_handler.spec.nack_on_exception
935
1280
  and exception is not None
936
1281
  ):
937
1282
  # Get retry config from consumer
@@ -940,12 +1285,20 @@ class MessageHandlerCallback:
940
1285
  # Check if we reached max retries
941
1286
  if retry_count >= retry_config.max_retries:
942
1287
  logger.warning(
943
- f"Message {message_id} ({self.queue_name}) failed after {retry_count} retries, "
944
- f"dead-lettering: {str(exception)}"
1288
+ "Message %s (%s) failed after %s retries, dead-lettering: %s",
1289
+ message_id,
1290
+ self.queue_name,
1291
+ retry_count,
1292
+ str(exception),
945
1293
  )
946
1294
  # Dead-letter the message after max retries
947
- async with self.consumer.get_channel_ctx(self.queue_name):
1295
+ try:
1296
+
948
1297
  await aio_pika_message.reject(requeue=False)
1298
+ except Exception as e:
1299
+ logger.error(
1300
+ "Failed to dead-letter message %s: %s", message_id, e
1301
+ )
949
1302
  return
950
1303
 
951
1304
  # Calculate delay for this retry attempt
@@ -961,9 +1314,14 @@ class MessageHandlerCallback:
961
1314
 
962
1315
  delay = min(delay, retry_config.max_delay)
963
1316
 
964
- logger.info(
965
- f"Message {message_id} ({self.queue_name}) failed with {str(exception)}, "
966
- f"retry {retry_count+1}/{retry_config.max_retries} scheduled in {delay:.2f}s"
1317
+ logger.warning(
1318
+ "Message %s (%s) failed with %s, retry %s/%s scheduled in %.2fs",
1319
+ message_id,
1320
+ self.queue_name,
1321
+ str(exception),
1322
+ retry_count + 1,
1323
+ retry_config.max_retries,
1324
+ delay,
967
1325
  )
968
1326
 
969
1327
  # Store retry state for this message
@@ -977,33 +1335,44 @@ class MessageHandlerCallback:
977
1335
  asyncio.create_task(
978
1336
  self._delayed_retry(
979
1337
  aio_pika_message, delay, retry_count + 1, exception
980
- )
1338
+ ),
1339
+ name=f"MessageHandler-{self.queue_name}-delayed-retry-{message_id}",
981
1340
  )
982
1341
 
983
1342
  # Acknowledge the current message since we'll handle retry ourselves
984
- async with self.consumer.get_channel_ctx(self.queue_name):
1343
+ try:
985
1344
  await aio_pika_message.ack()
1345
+ except Exception as e:
1346
+ logger.error(
1347
+ "Failed to acknowledge message %s for retry: %s", message_id, e
1348
+ )
986
1349
  return
987
1350
 
988
1351
  # Standard reject without retry or with immediate requeue
989
- async with self.consumer.get_channel_ctx(self.queue_name):
990
- await aio_pika_message.reject(requeue=requeue)
991
- if requeue:
992
- logger.info(
993
- f"Message {message_id} ({self.queue_name}) requeued for immediate retry"
994
- )
995
- else:
996
- logger.info(
997
- f"Message {message_id} ({self.queue_name}) rejected without requeue"
998
- )
1352
+ try:
1353
+ async with self.consumer.get_channel_ctx(self.queue_name):
1354
+ await aio_pika_message.reject(requeue=requeue)
1355
+ if requeue:
1356
+ logger.warning(
1357
+ "Message %s (%s) requeued for immediate retry",
1358
+ message_id,
1359
+ self.queue_name,
1360
+ )
1361
+ else:
1362
+ logger.warning(
1363
+ "Message %s (%s) rejected without requeue",
1364
+ message_id,
1365
+ self.queue_name,
1366
+ )
1367
+ except Exception as e:
1368
+ logger.error("Failed to reject message %s: %s", message_id, e)
999
1369
 
1000
- except RuntimeError as e:
1001
- logger.error(
1002
- f"Error rejecting message {message_id} ({self.queue_name}): {e}"
1003
- )
1004
1370
  except Exception as e:
1005
1371
  logger.exception(
1006
- f"Unexpected error rejecting message {message_id} ({self.queue_name}): {e}"
1372
+ "Unexpected error in handle_reject_message for %s (%s): %s",
1373
+ message_id,
1374
+ self.queue_name,
1375
+ e,
1007
1376
  )
1008
1377
 
1009
1378
  async def _delayed_retry(
@@ -1018,7 +1387,7 @@ class MessageHandlerCallback:
1018
1387
 
1019
1388
  Args:
1020
1389
  aio_pika_message: The original message
1021
- delay: Delay in seconds before retry
1390
+ delay: Delay in seconds before retrying
1022
1391
  retry_count: The current retry count (after increment)
1023
1392
  exception: The exception that caused the failure
1024
1393
  """
@@ -1026,7 +1395,7 @@ class MessageHandlerCallback:
1026
1395
 
1027
1396
  try:
1028
1397
  # Wait for the backoff delay
1029
- await asyncio.sleep(delay)
1398
+ await self._wait_delay_or_shutdown(delay)
1030
1399
 
1031
1400
  # Get message body and properties for republishing
1032
1401
  message_body = aio_pika_message.body
@@ -1043,32 +1412,62 @@ class MessageHandlerCallback:
1043
1412
  if message_id in self.retry_state:
1044
1413
  del self.retry_state[message_id]
1045
1414
 
1046
- # Republish the message to the same queue
1047
- async with self.consumer.get_channel_ctx(self.queue_name) as channel:
1048
- exchange = await RabbitmqUtils.get_main_exchange(
1049
- channel=channel,
1050
- exchange_name=self.consumer.config.exchange,
1051
- )
1415
+ # Republish the message to the same queue with retry logic
1416
+ max_attempts = 3
1417
+ for attempt in range(max_attempts):
1418
+ try:
1419
+ async with self.consumer.get_channel_ctx(
1420
+ self.queue_name
1421
+ ) as channel:
1422
+ exchange = await RabbitmqUtils.get_main_exchange(
1423
+ channel=channel,
1424
+ exchange_name=self.consumer.config.exchange,
1425
+ )
1052
1426
 
1053
- await exchange.publish(
1054
- aio_pika.Message(
1055
- body=message_body,
1056
- headers=headers,
1057
- message_id=message_id,
1058
- content_type=aio_pika_message.content_type,
1059
- content_encoding=aio_pika_message.content_encoding,
1060
- delivery_mode=aio_pika_message.delivery_mode,
1061
- ),
1062
- routing_key=self.routing_key,
1063
- )
1427
+ await exchange.publish(
1428
+ aio_pika.Message(
1429
+ body=message_body,
1430
+ headers=headers,
1431
+ message_id=message_id,
1432
+ content_type=aio_pika_message.content_type,
1433
+ content_encoding=aio_pika_message.content_encoding,
1434
+ delivery_mode=aio_pika_message.delivery_mode,
1435
+ ),
1436
+ routing_key=self.routing_key,
1437
+ )
1064
1438
 
1065
- logger.info(
1066
- f"Message {message_id} ({self.queue_name}) republished for retry {retry_count}"
1067
- )
1439
+ logger.warning(
1440
+ "Message %s (%s) republished for retry %s",
1441
+ message_id,
1442
+ self.queue_name,
1443
+ retry_count,
1444
+ )
1445
+ return
1446
+
1447
+ except Exception as e:
1448
+ if attempt < max_attempts - 1:
1449
+ logger.warning(
1450
+ "Failed to republish message %s (attempt %s): %s",
1451
+ message_id,
1452
+ attempt + 1,
1453
+ e,
1454
+ )
1455
+ await asyncio.sleep(1.0 * (attempt + 1)) # Exponential backoff
1456
+ else:
1457
+ logger.error(
1458
+ "Failed to republish message %s after %s attempts: %s",
1459
+ message_id,
1460
+ max_attempts,
1461
+ e,
1462
+ )
1463
+ raise
1068
1464
 
1069
1465
  except Exception as e:
1070
1466
  logger.exception(
1071
- f"Failed to execute delayed retry for message {message_id} ({self.queue_name}): {e}"
1467
+ "Failed to execute delayed retry for message %s (%s): %s",
1468
+ message_id,
1469
+ self.queue_name,
1470
+ e,
1072
1471
  )
1073
1472
  # If we fail to republish, try to dead-letter the original message
1074
1473
  try:
@@ -1077,6 +1476,24 @@ class MessageHandlerCallback:
1077
1476
  except Exception:
1078
1477
  pass
1079
1478
 
1479
+ async def _wait_delay_or_shutdown(self, delay: float) -> None:
1480
+ """
1481
+ Wait for the specified delay or exit early if shutdown is initiated.
1482
+
1483
+ Args:
1484
+ delay: Delay in seconds to wait
1485
+ """
1486
+
1487
+ wait_cor = asyncio.create_task(asyncio.sleep(delay), name="delayed-retry-wait")
1488
+ wait_shutdown_cor = asyncio.create_task(
1489
+ self.consumer.shutdown_event.wait(), name="delayed-retry-shutdown-wait"
1490
+ )
1491
+
1492
+ await asyncio.wait(
1493
+ [wait_cor, wait_shutdown_cor],
1494
+ return_when=asyncio.FIRST_COMPLETED,
1495
+ )
1496
+
1080
1497
  async def handle_message(
1081
1498
  self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
1082
1499
  ) -> None:
@@ -1130,70 +1547,89 @@ class MessageHandlerCallback:
1130
1547
 
1131
1548
  builded_message = AioPikaMessage(aio_pika_message, message_type)
1132
1549
 
1133
- incoming_message_spec = MessageHandler.get_message_incoming(handler)
1550
+ incoming_message_spec = MessageHandler.get_last(handler)
1134
1551
  assert incoming_message_spec is not None
1135
1552
 
1136
- async with self.consumer.uow_context_provider(
1137
- AppTransactionContext(
1138
- controller_member_reflect=handler_data.controller_member,
1139
- transaction_data=MessageBusTransactionData(
1140
- message=builded_message,
1141
- topic=routing_key,
1142
- ),
1143
- )
1553
+ with provide_implicit_headers(aio_pika_message.headers), provide_shutdown_state(
1554
+ self.consumer.shutdown_state
1144
1555
  ):
1145
- ctx: AsyncContextManager[Any]
1146
- if incoming_message_spec.timeout is not None:
1147
- ctx = asyncio.timeout(incoming_message_spec.timeout)
1148
- else:
1149
- ctx = none_context()
1150
- async with ctx:
1151
- try:
1152
- with provide_bus_message_controller(
1153
- AioPikaMessageBusController(aio_pika_message)
1154
- ):
1155
- await handler(builded_message)
1156
- if not incoming_message_spec.auto_ack:
1157
- with suppress(aio_pika.MessageProcessError):
1158
- # Use channel context for acknowledgement
1159
- async with self.consumer.get_channel_ctx(self.queue_name):
1160
- await aio_pika_message.ack()
1161
- except BaseException as base_exc:
1162
- # Get message id for logging
1163
- message_id = aio_pika_message.message_id or str(uuid.uuid4())
1164
-
1165
- # Extract retry count from headers if available
1166
- headers = aio_pika_message.headers or {}
1167
- retry_count = int(str(headers.get("x-retry-count", 0)))
1556
+ async with self.consumer.uow_context_provider(
1557
+ AppTransactionContext(
1558
+ controller_member_reflect=handler_data.controller_member,
1559
+ transaction_data=MessageBusTransactionData(
1560
+ message_type=message_type,
1561
+ message=builded_message,
1562
+ topic=routing_key,
1563
+ ),
1564
+ )
1565
+ ):
1566
+ maybe_timeout_ctx: AsyncContextManager[Any]
1567
+ if incoming_message_spec.timeout is not None:
1568
+ maybe_timeout_ctx = asyncio.timeout(incoming_message_spec.timeout)
1569
+ else:
1570
+ maybe_timeout_ctx = none_context()
1571
+
1572
+ start_time = time.perf_counter()
1573
+ async with maybe_timeout_ctx:
1574
+ try:
1575
+ with provide_bus_message_controller(
1576
+ AioPikaMessageBusController(aio_pika_message)
1577
+ ):
1578
+ await handler(builded_message)
1579
+ with suppress(aio_pika.MessageProcessError):
1580
+ # Use channel context for acknowledgement with retry
1581
+ try:
1582
+ await aio_pika_message.ack()
1583
+ except Exception as ack_error:
1584
+ logger.warning(
1585
+ "Failed to acknowledge message %s: %s",
1586
+ aio_pika_message.message_id or "unknown",
1587
+ ack_error,
1588
+ )
1589
+ successfully = True
1590
+ except BaseException as base_exc:
1591
+ successfully = False
1592
+ # Get message id for logging
1593
+ message_id = aio_pika_message.message_id or "unknown"
1594
+
1595
+ # Extract retry count from headers if available
1596
+ headers = aio_pika_message.headers or {}
1597
+ retry_count = int(str(headers.get("x-retry-count", 0)))
1168
1598
 
1169
- # Process exception handler if configured
1170
- if incoming_message_spec.exception_handler is not None:
1171
- try:
1172
- incoming_message_spec.exception_handler(base_exc)
1173
- except Exception as nested_exc:
1599
+ # Process exception handler if configured
1600
+ if incoming_message_spec.exception_handler is not None:
1601
+ try:
1602
+ incoming_message_spec.exception_handler(base_exc)
1603
+ except Exception as nested_exc:
1604
+ logger.exception(
1605
+ "Error processing exception handler for message %s: %s | %s",
1606
+ message_id,
1607
+ base_exc,
1608
+ nested_exc,
1609
+ )
1610
+ else:
1174
1611
  logger.exception(
1175
- f"Error processing exception handler for message {message_id}: {base_exc} | {nested_exc}"
1612
+ "Error processing message %s on topic %s: %s",
1613
+ message_id,
1614
+ routing_key,
1615
+ str(base_exc),
1176
1616
  )
1177
- else:
1178
- logger.exception(
1179
- f"Error processing message {message_id} on topic {routing_key}: {str(base_exc)}"
1180
- )
1181
1617
 
1182
- # Handle rejection with retry logic
1183
- if incoming_message_spec.requeue_on_exception:
1184
- # Use our retry with backoff mechanism
1185
- await self.handle_reject_message(
1186
- aio_pika_message,
1187
- requeue=False, # Don't requeue directly, use our backoff mechanism
1188
- retry_count=retry_count,
1189
- exception=base_exc,
1190
- )
1191
- else:
1192
- # Message shouldn't be retried, reject it
1193
- await self.handle_reject_message(
1194
- aio_pika_message, requeue=False, exception=base_exc
1195
- )
1196
- else:
1618
+ # Handle rejection with retry logic
1619
+ if incoming_message_spec.nack_on_exception:
1620
+ await self.handle_reject_message(
1621
+ aio_pika_message,
1622
+ requeue=False, # Don't requeue directly, use our backoff mechanism
1623
+ retry_count=retry_count,
1624
+ exception=base_exc,
1625
+ )
1626
+ else:
1627
+ # Message shouldn't be retried, reject it
1628
+ await self.handle_reject_message(
1629
+ aio_pika_message, requeue=False, exception=base_exc
1630
+ )
1631
+
1632
+ elapsed_time = time.perf_counter() - start_time
1197
1633
  # Message processed successfully, log and clean up any retry state
1198
1634
  message_id = aio_pika_message.message_id or str(uuid.uuid4())
1199
1635
  if message_id in self.retry_state:
@@ -1201,14 +1637,32 @@ class MessageHandlerCallback:
1201
1637
 
1202
1638
  # Log success with retry information if applicable
1203
1639
  headers = aio_pika_message.headers or {}
1640
+ traceparent = headers.get("traceparent")
1641
+ trace_info = (
1642
+ f" [traceparent={str(traceparent)}]" if traceparent else ""
1643
+ )
1644
+
1204
1645
  if "x-retry-count" in headers:
1205
1646
  retry_count = int(str(headers.get("x-retry-count", 0)))
1206
- logger.info(
1207
- f"Message {message_id}#{self.queue_name} processed successfully after {retry_count} retries"
1647
+ logger.debug(
1648
+ "Message %s#%s processed "
1649
+ + ("successfully" if successfully else "with errors")
1650
+ + " after %s retries in %.4fs%s",
1651
+ message_id,
1652
+ self.queue_name,
1653
+ retry_count,
1654
+ elapsed_time,
1655
+ trace_info,
1208
1656
  )
1209
1657
  else:
1210
- logger.info(
1211
- f"Message {message_id}#{self.queue_name} processed successfully"
1658
+ logger.debug(
1659
+ "Message %s#%s processed "
1660
+ + ("successfully" if successfully else "with errors")
1661
+ + " in %.4fs%s",
1662
+ message_id,
1663
+ self.queue_name,
1664
+ elapsed_time,
1665
+ trace_info,
1212
1666
  )
1213
1667
 
1214
1668
 
@@ -1248,68 +1702,73 @@ class MessageBusWorker:
1248
1702
  async def start_async(self) -> None:
1249
1703
  all_message_handlers_set: MESSAGE_HANDLER_DATA_SET = set()
1250
1704
  all_scheduled_actions_set: SCHEDULED_ACTION_DATA_SET = set()
1251
- async with self.lifecycle():
1252
- for instance_class in self.app.controllers:
1253
- controller = MessageBusController.get_messagebus(instance_class)
1254
-
1255
- if controller is None:
1256
- continue
1257
-
1258
- instance: Any = self.container.get_by_type(instance_class)
1259
-
1260
- factory = controller.get_messagebus_factory()
1261
- handlers, schedulers = factory(instance)
1262
-
1263
- message_handler_data_map: dict[str, MessageHandlerData] = {}
1264
- all_scheduled_actions_set.update(schedulers)
1265
- for handler_data in handlers:
1266
- message_type = handler_data.spec.message_type
1267
- topic = message_type.MESSAGE_TOPIC
1268
-
1269
- # Filter handlers by name if specified
1270
- if (
1271
- self.handler_names is not None
1272
- and handler_data.spec.name is not None
1273
- ):
1274
- if handler_data.spec.name not in self.handler_names:
1275
- continue
1276
- elif (
1277
- self.handler_names is not None
1278
- and handler_data.spec.name is None
1279
- ):
1280
- # Skip handlers without names when filtering is requested
1281
- continue
1705
+ with providing_app_type("worker"):
1706
+ async with self.lifecycle():
1707
+ for instance_class in self.app.controllers:
1708
+ controller = MessageBusController.get_last(instance_class)
1282
1709
 
1283
- if (
1284
- topic in message_handler_data_map
1285
- and message_type.MESSAGE_TYPE == "task"
1286
- ):
1287
- logger.warning(
1288
- "Task handler for topic '%s' already registered. Skipping"
1289
- % topic
1290
- )
1710
+ if controller is None:
1291
1711
  continue
1292
- message_handler_data_map[topic] = handler_data
1293
- all_message_handlers_set.add(handler_data)
1294
1712
 
1295
- broker_backend = get_message_broker_backend_from_url(url=self.backend_url)
1713
+ instance: Any = self.container.get_by_type(instance_class)
1714
+
1715
+ factory = controller.get_messagebus_factory()
1716
+ handlers, schedulers = factory(instance)
1717
+
1718
+ message_handler_data_map: dict[str, MessageHandlerData] = {}
1719
+ all_scheduled_actions_set.update(schedulers)
1720
+ for handler_data in handlers:
1721
+ message_type = handler_data.spec.message_type
1722
+ topic = message_type.MESSAGE_TOPIC
1723
+
1724
+ # Filter handlers by name if specified
1725
+ if (
1726
+ self.handler_names is not None
1727
+ and handler_data.spec.name is not None
1728
+ ):
1729
+ if handler_data.spec.name not in self.handler_names:
1730
+ continue
1731
+ elif (
1732
+ self.handler_names is not None
1733
+ and handler_data.spec.name is None
1734
+ ):
1735
+ # Skip handlers without names when filtering is requested
1736
+ continue
1296
1737
 
1297
- consumer = self._consumer = create_message_bus(
1298
- broker_url=self.broker_url,
1299
- broker_backend=broker_backend,
1300
- scheduled_actions=all_scheduled_actions_set,
1301
- message_handler_set=all_message_handlers_set,
1302
- uow_context_provider=self.uow_context_provider,
1303
- )
1738
+ if (
1739
+ topic in message_handler_data_map
1740
+ and message_type.MESSAGE_TYPE == "task"
1741
+ ):
1742
+ logger.warning(
1743
+ "Task handler for topic '%s' already registered. Skipping"
1744
+ % topic
1745
+ )
1746
+ continue
1747
+ message_handler_data_map[topic] = handler_data
1748
+ all_message_handlers_set.add(handler_data)
1304
1749
 
1305
- await consumer.consume()
1750
+ broker_backend = get_message_broker_backend_from_url(
1751
+ url=self.backend_url
1752
+ )
1753
+
1754
+ consumer = self._consumer = create_message_bus(
1755
+ broker_url=self.broker_url,
1756
+ broker_backend=broker_backend,
1757
+ scheduled_actions=all_scheduled_actions_set,
1758
+ message_handler_set=all_message_handlers_set,
1759
+ uow_context_provider=self.uow_context_provider,
1760
+ )
1761
+
1762
+ await consumer.consume()
1306
1763
 
1307
1764
  def start_sync(self) -> None:
1308
1765
 
1309
1766
  def on_shutdown(loop: asyncio.AbstractEventLoop) -> None:
1310
- logger.info("Shutting down - signal received")
1767
+ logger.warning("Shutting down - signal received")
1311
1768
  # Schedule the shutdown to run in the event loop
1312
- asyncio.create_task(self._graceful_shutdown())
1769
+ asyncio.create_task(
1770
+ self._graceful_shutdown(), name="Worker-Graceful-Shutdown"
1771
+ )
1313
1772
  # wait until the shutdown is complete
1314
1773
 
1315
1774
  with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
@@ -1317,15 +1776,22 @@ class MessageBusWorker:
1317
1776
  loop.add_signal_handler(signal.SIGINT, on_shutdown, loop)
1318
1777
  # Add graceful shutdown handler for SIGTERM as well
1319
1778
  loop.add_signal_handler(signal.SIGTERM, on_shutdown, loop)
1320
- runner.run(self.start_async())
1779
+ try:
1780
+ runner.run(self.start_async())
1781
+ except Exception as e:
1782
+ logger.critical("Worker failed to start due to connection error: %s", e)
1783
+ # Exit with error code 1 to indicate startup failure
1784
+ import sys
1785
+
1786
+ sys.exit(1)
1321
1787
 
1322
1788
  async def _graceful_shutdown(self) -> None:
1323
1789
  """Handles graceful shutdown process"""
1324
- logger.info("Initiating graceful shutdown sequence")
1790
+ logger.warning("Initiating graceful shutdown sequence")
1325
1791
  # Use the comprehensive close method that handles shutdown, task waiting and connection cleanup
1326
1792
 
1327
1793
  self.consumer.shutdown()
1328
- logger.info("Graceful shutdown completed")
1794
+ logger.warning("Graceful shutdown completed")
1329
1795
 
1330
1796
 
1331
1797
  class AioPikaMessageBusController(BusMessageController):
@@ -1404,13 +1870,14 @@ class AioPikaMessageBusController(BusMessageController):
1404
1870
  float(delay),
1405
1871
  retry_count + 1,
1406
1872
  None, # No specific exception
1407
- )
1873
+ ),
1874
+ name=f"MessageHandler-{callback.queue_name}-delayed-retry-{self.aio_pika_message.message_id or 'unknown'}-{int(time.time())}",
1408
1875
  )
1409
1876
 
1410
1877
  # Acknowledge the current message since we'll republish
1411
1878
  await self.aio_pika_message.ack()
1412
1879
 
1413
1880
  except Exception as e:
1414
- logger.exception(f"Failed to schedule retry_later: {e}")
1881
+ logger.exception("Failed to schedule retry_later: %s", e)
1415
1882
  # Fall back to immediate retry
1416
1883
  await self.aio_pika_message.reject(requeue=True)