jararaca 0.3.11a16__py3-none-any.whl → 0.3.12__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.

Potentially problematic release.


This version of jararaca might be problematic. Click here for more details.

@@ -23,7 +23,14 @@ from urllib.parse import parse_qs, urlparse
23
23
  import aio_pika
24
24
  import aio_pika.abc
25
25
  import uvloop
26
- from aio_pika.exceptions import AMQPError, ChannelClosed, ChannelNotFoundEntity
26
+ from aio_pika.exceptions import (
27
+ AMQPChannelError,
28
+ AMQPConnectionError,
29
+ AMQPError,
30
+ ChannelClosed,
31
+ ChannelNotFoundEntity,
32
+ ConnectionClosed,
33
+ )
27
34
  from pydantic import BaseModel
28
35
 
29
36
  from jararaca.broker_backend import MessageBrokerBackend
@@ -49,6 +56,8 @@ from jararaca.microservice import (
49
56
  MessageBusTransactionData,
50
57
  Microservice,
51
58
  SchedulerTransactionData,
59
+ ShutdownState,
60
+ provide_shutdown_state,
52
61
  )
53
62
  from jararaca.scheduler.decorators import ScheduledActionData
54
63
  from jararaca.utils.rabbitmq_utils import RabbitmqUtils
@@ -78,6 +87,18 @@ class AioPikaWorkerConfig:
78
87
  backoff_factor=2.0,
79
88
  )
80
89
  )
90
+ # Connection health monitoring settings
91
+ connection_heartbeat_interval: float = 30.0 # seconds
92
+ connection_health_check_interval: float = 10.0 # seconds
93
+ reconnection_backoff_config: RetryConfig = field(
94
+ default_factory=lambda: RetryConfig(
95
+ max_retries=-1, # Infinite retries for reconnection
96
+ initial_delay=2.0,
97
+ max_delay=120.0,
98
+ backoff_factor=2.0,
99
+ jitter=True,
100
+ )
101
+ )
81
102
 
82
103
 
83
104
  class AioPikaMessage(MessageOf[Message]):
@@ -129,6 +150,17 @@ class MessageBusConsumer(ABC):
129
150
  """Close all resources related to the consumer"""
130
151
 
131
152
 
153
+ class _WorkerShutdownState(ShutdownState):
154
+ def __init__(self, shutdown_event: asyncio.Event):
155
+ self.shutdown_event = shutdown_event
156
+
157
+ def request_shutdown(self) -> None:
158
+ self.shutdown_event.set()
159
+
160
+ def is_shutdown_requested(self) -> bool:
161
+ return self.shutdown_event.is_set()
162
+
163
+
132
164
  class AioPikaMicroserviceConsumer(MessageBusConsumer):
133
165
  def __init__(
134
166
  self,
@@ -146,11 +178,21 @@ 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.reconnection_event = asyncio.Event()
191
+ self.reconnection_in_progress = False
192
+ self.consumer_tags: dict[str, str] = {} # Track consumer tags for cleanup
193
+ self.health_check_task: asyncio.Task[Any] | None = None
194
+ self.reconnection_task: asyncio.Task[Any] | None = None
195
+
154
196
  async def _verify_infrastructure(self) -> bool:
155
197
  """
156
198
  Verify that the required RabbitMQ infrastructure (exchanges, queues) exists.
@@ -186,14 +228,18 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
186
228
  routing_key = f"{handler.message_type.MESSAGE_TOPIC}.#"
187
229
 
188
230
  async def setup_consumer() -> None:
231
+ # Wait for connection to be healthy if reconnection is in progress
232
+ if self.reconnection_in_progress:
233
+ await self.reconnection_event.wait()
234
+
189
235
  # Create a channel using the context manager
190
236
  async with self.create_channel(queue_name) as channel:
191
237
  queue = await RabbitmqUtils.get_queue(
192
238
  channel=channel, queue_name=queue_name
193
239
  )
194
240
 
195
- # Configure consumer right away while in the context
196
- await queue.consume(
241
+ # Configure consumer and get the consumer tag
242
+ consumer_tag = await queue.consume(
197
243
  callback=MessageHandlerCallback(
198
244
  consumer=self,
199
245
  queue_name=queue_name,
@@ -203,6 +249,9 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
203
249
  no_ack=handler.spec.auto_ack,
204
250
  )
205
251
 
252
+ # Store consumer tag for cleanup
253
+ self.consumer_tags[queue_name] = consumer_tag
254
+
206
255
  logger.info(
207
256
  f"Consuming message handler {queue_name} on dedicated channel"
208
257
  )
@@ -212,7 +261,14 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
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:
@@ -232,14 +288,18 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
232
288
  routing_key = queue_name
233
289
 
234
290
  async def setup_consumer() -> None:
291
+ # Wait for connection to be healthy if reconnection is in progress
292
+ if self.reconnection_in_progress:
293
+ await self.reconnection_event.wait()
294
+
235
295
  # Create a channel using the context manager
236
296
  async with self.create_channel(queue_name) as channel:
237
297
  queue = await RabbitmqUtils.get_queue(
238
298
  channel=channel, queue_name=queue_name
239
299
  )
240
300
 
241
- # Configure consumer right away while in the context
242
- await queue.consume(
301
+ # Configure consumer and get the consumer tag
302
+ consumer_tag = await queue.consume(
243
303
  callback=ScheduledMessageHandlerCallback(
244
304
  consumer=self,
245
305
  queue_name=queue_name,
@@ -249,6 +309,9 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
249
309
  no_ack=True,
250
310
  )
251
311
 
312
+ # Store consumer tag for cleanup
313
+ self.consumer_tags[queue_name] = consumer_tag
314
+
252
315
  logger.info(f"Consuming scheduler {queue_name} on dedicated channel")
253
316
 
254
317
  try:
@@ -256,7 +319,14 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
256
319
  await retry_with_backoff(
257
320
  setup_consumer,
258
321
  retry_config=self.config.consumer_retry_config,
259
- retry_exceptions=(ChannelNotFoundEntity, ChannelClosed, AMQPError),
322
+ retry_exceptions=(
323
+ ChannelNotFoundEntity,
324
+ ChannelClosed,
325
+ AMQPError,
326
+ AMQPConnectionError,
327
+ AMQPChannelError,
328
+ ConnectionClosed,
329
+ ),
260
330
  )
261
331
  return True
262
332
  except Exception as e:
@@ -269,98 +339,107 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
269
339
  """
270
340
  Main consume method that sets up all message handlers and scheduled actions with retry mechanisms.
271
341
  """
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
- )
342
+ # Establish initial connection
343
+ async with self.connect() as connection:
344
+ self.connection_healthy = True
278
345
 
279
- if not infra_check_success:
280
- logger.critical("Failed to verify RabbitMQ infrastructure. Shutting down.")
281
- self.shutdown_event.set()
282
- return
346
+ # Start connection health monitoring
347
+ self.health_check_task = asyncio.create_task(
348
+ self._monitor_connection_health()
349
+ )
350
+
351
+ # Verify infrastructure with retry
352
+ infra_check_success = await retry_with_backoff(
353
+ self._verify_infrastructure,
354
+ retry_config=self.config.connection_retry_config,
355
+ retry_exceptions=(Exception,),
356
+ )
283
357
 
284
- async def wait_for(
285
- type: str, name: str, coroutine: Awaitable[bool]
286
- ) -> tuple[str, str, bool]:
287
- return type, name, await coroutine
358
+ if not infra_check_success:
359
+ logger.critical(
360
+ "Failed to verify RabbitMQ infrastructure. Shutting down."
361
+ )
362
+ self.shutdown_event.set()
363
+ return
288
364
 
289
- tasks: set[asyncio.Task[tuple[str, str, bool]]] = set()
365
+ async def wait_for(
366
+ type: str, name: str, coroutine: Awaitable[bool]
367
+ ) -> tuple[str, str, bool]:
368
+ return type, name, await coroutine
290
369
 
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
295
-
296
- tasks.add(
297
- task := asyncio.create_task(
298
- wait_for(
299
- "message_handler",
300
- queue_name,
301
- self._setup_message_handler_consumer(handler),
370
+ tasks: set[asyncio.Task[tuple[str, str, bool]]] = set()
371
+
372
+ # Setup message handlers
373
+ for handler in self.message_handler_set:
374
+ queue_name = f"{handler.message_type.MESSAGE_TOPIC}.{handler.instance_callable.__module__}.{handler.instance_callable.__qualname__}"
375
+ self.incoming_map[queue_name] = handler
376
+
377
+ tasks.add(
378
+ task := asyncio.create_task(
379
+ wait_for(
380
+ "message_handler",
381
+ queue_name,
382
+ self._setup_message_handler_consumer(handler),
383
+ )
302
384
  )
303
385
  )
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
386
 
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),
387
+ # Setup scheduled actions
388
+ for scheduled_action in self.scheduled_actions:
389
+ queue_name = f"{scheduled_action.callable.__module__}.{scheduled_action.callable.__qualname__}"
390
+ tasks.add(
391
+ task := asyncio.create_task(
392
+ wait_for(
393
+ "scheduled_action",
394
+ queue_name,
395
+ self._setup_scheduled_action_consumer(scheduled_action),
396
+ )
322
397
  )
323
398
  )
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"
342
- )
343
399
 
344
- handle_task_results_task = asyncio.create_task(handle_task_results())
345
-
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()
400
+ async def handle_task_results() -> None:
401
+ for task in asyncio.as_completed(tasks):
402
+ type, name, success = await task
403
+ if success:
404
+ logger.info(f"Successfully set up {type} consumer for {name}")
405
+ else:
406
+ logger.warning(
407
+ f"Failed to set up {type} consumer for {name}, will not process messages from this queue"
408
+ )
409
+
410
+ handle_task_results_task = asyncio.create_task(handle_task_results())
411
+
412
+ # Wait for shutdown signal
413
+ await self.shutdown_event.wait()
414
+ logger.info("Shutdown event received, stopping consumers")
415
+
416
+ # Cancel health monitoring
417
+ if self.health_check_task:
418
+ self.health_check_task.cancel()
355
419
  with suppress(asyncio.CancelledError):
356
- await task
357
- logger.info("Worker shutting down")
420
+ await self.health_check_task
358
421
 
359
- # Wait for all tasks to complete
360
- await self.wait_all_tasks_done()
422
+ # Cancel reconnection task if running
423
+ if self.reconnection_task:
424
+ self.reconnection_task.cancel()
425
+ with suppress(asyncio.CancelledError):
426
+ await self.reconnection_task
361
427
 
362
- # Close all channels and the connection
363
- await self.close_channels_and_connection()
428
+ handle_task_results_task.cancel()
429
+ with suppress(asyncio.CancelledError):
430
+ await handle_task_results_task
431
+ for task in tasks:
432
+ if not task.done():
433
+ task.cancel()
434
+ with suppress(asyncio.CancelledError):
435
+ await task
436
+ logger.info("Worker shutting down")
437
+
438
+ # Wait for all tasks to complete
439
+ await self.wait_all_tasks_done()
440
+
441
+ # Close all channels and the connection
442
+ await self.close_channels_and_connection()
364
443
 
365
444
  async def wait_all_tasks_done(self) -> None:
366
445
  if not self.tasks:
@@ -379,41 +458,8 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
379
458
 
380
459
  async def close_channels_and_connection(self) -> None:
381
460
  """Close all channels and then the connection"""
382
- # Close all channels
383
- channel_close_tasks = []
384
- for queue_name, channel in self.channels.items():
385
- try:
386
- 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")
391
- except Exception as e:
392
- logger.error(
393
- f"Error preparing to close channel for queue {queue_name}: {e}"
394
- )
395
-
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}")
402
-
403
- # Clear channels dictionary
404
- self.channels.clear()
405
-
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
461
+ logger.info("Closing channels and connection...")
462
+ await self._cleanup_connection()
417
463
 
418
464
  def shutdown(self) -> None:
419
465
  """Signal for shutdown"""
@@ -422,7 +468,21 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
422
468
 
423
469
  async def close(self) -> None:
424
470
  """Implement MessageBusConsumer.close for cleanup"""
471
+ logger.info("Closing consumer...")
425
472
  self.shutdown()
473
+
474
+ # Cancel health monitoring
475
+ if self.health_check_task:
476
+ self.health_check_task.cancel()
477
+ with suppress(asyncio.CancelledError):
478
+ await self.health_check_task
479
+
480
+ # Cancel reconnection task if running
481
+ if self.reconnection_task:
482
+ self.reconnection_task.cancel()
483
+ with suppress(asyncio.CancelledError):
484
+ await self.reconnection_task
485
+
426
486
  await self.wait_all_tasks_done()
427
487
  await self.close_channels_and_connection()
428
488
 
@@ -431,6 +491,16 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
431
491
  Get the channel for a specific queue, or None if not found.
432
492
  This helps with error handling when a channel might have been closed.
433
493
  """
494
+ # If reconnection is in progress, wait for it to complete
495
+ if self.reconnection_in_progress:
496
+ try:
497
+ await asyncio.wait_for(self.reconnection_event.wait(), timeout=30.0)
498
+ except asyncio.TimeoutError:
499
+ logger.warning(
500
+ f"Timeout waiting for reconnection when getting channel for {queue_name}"
501
+ )
502
+ return None
503
+
434
504
  if queue_name not in self.channels:
435
505
  logger.warning(f"No channel found for queue {queue_name}")
436
506
  return None
@@ -439,18 +509,38 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
439
509
  channel = self.channels[queue_name]
440
510
  if channel.is_closed:
441
511
  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
512
+ # Remove the closed channel
513
+ del self.channels[queue_name]
514
+
515
+ # Attempt to recreate the channel if connection is healthy
516
+ if (
517
+ self.connection
518
+ and not self.connection.is_closed
519
+ and self.connection_healthy
520
+ ):
521
+ try:
522
+ logger.info(f"Creating new channel for {queue_name}")
523
+ self.channels[queue_name] = await self.connection.channel()
524
+ await self.channels[queue_name].set_qos(
525
+ prefetch_count=self.config.prefetch_count
526
+ )
527
+ return self.channels[queue_name]
528
+ except Exception as e:
529
+ logger.error(
530
+ f"Failed to recreate channel for {queue_name}: {e}"
531
+ )
532
+ # Trigger reconnection if channel creation fails
533
+ self._trigger_reconnection()
534
+ return None
535
+ else:
536
+ # Connection is not healthy, trigger reconnection
537
+ self._trigger_reconnection()
538
+ return None
451
539
  return channel
452
540
  except Exception as e:
453
541
  logger.error(f"Error accessing channel for queue {queue_name}: {e}")
542
+ # Trigger reconnection on any channel access error
543
+ self._trigger_reconnection()
454
544
  return None
455
545
 
456
546
  async def _establish_channel(self, queue_name: str) -> aio_pika.abc.AbstractChannel:
@@ -483,8 +573,8 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
483
573
  fn=lambda: self._establish_channel(queue_name),
484
574
  retry_config=self.config.consumer_retry_config,
485
575
  retry_exceptions=(
486
- aio_pika.exceptions.AMQPConnectionError,
487
- aio_pika.exceptions.AMQPChannelError,
576
+ AMQPConnectionError,
577
+ AMQPChannelError,
488
578
  ConnectionError,
489
579
  ),
490
580
  )
@@ -511,7 +601,10 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
511
601
  """
512
602
  try:
513
603
  logger.info("Establishing connection to RabbitMQ")
514
- connection = await aio_pika.connect(self.config.url)
604
+ connection = await aio_pika.connect(
605
+ self.config.url,
606
+ heartbeat=self.config.connection_heartbeat_interval,
607
+ )
515
608
  logger.info("Connected to RabbitMQ successfully")
516
609
  return connection
517
610
  except Exception as e:
@@ -538,7 +631,7 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
538
631
  self._establish_connection,
539
632
  retry_config=self.config.connection_retry_config,
540
633
  retry_exceptions=(
541
- aio_pika.exceptions.AMQPConnectionError,
634
+ AMQPConnectionError,
542
635
  ConnectionError,
543
636
  OSError,
544
637
  TimeoutError,
@@ -572,22 +665,254 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
572
665
  Get a channel for a specific queue as a context manager.
573
666
  This is safer than using get_channel directly as it ensures proper error handling.
574
667
  """
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
- )
668
+ max_retries = 3
669
+ retry_delay = 1.0
670
+
671
+ for attempt in range(max_retries):
672
+ try:
673
+ channel = await self.get_channel(queue_name)
674
+ if channel is not None:
675
+ try:
676
+ yield channel
677
+ return
678
+ finally:
679
+ # We don't close the channel here as it's managed by the consumer
680
+ pass
681
+
682
+ # No channel available, check connection state
683
+ if (
684
+ self.connection
685
+ and not self.connection.is_closed
686
+ and self.connection_healthy
687
+ ):
688
+ # Try to create a new channel
689
+ async with self.create_channel(queue_name) as new_channel:
690
+ yield new_channel
691
+ return
692
+ else:
693
+ # Connection is not healthy, wait for reconnection
694
+ if self.reconnection_in_progress:
695
+ try:
696
+ await asyncio.wait_for(
697
+ self.reconnection_event.wait(), timeout=30.0
698
+ )
699
+ # Retry after reconnection
700
+ continue
701
+ except asyncio.TimeoutError:
702
+ logger.warning(
703
+ f"Timeout waiting for reconnection for queue {queue_name}"
704
+ )
705
+
706
+ # Still no connection, trigger reconnection
707
+ if not self.reconnection_in_progress:
708
+ self._trigger_reconnection()
709
+
710
+ if attempt < max_retries - 1:
711
+ logger.info(
712
+ f"Retrying channel access for {queue_name} in {retry_delay}s"
713
+ )
714
+ await asyncio.sleep(retry_delay)
715
+ retry_delay *= 2
716
+ else:
717
+ raise RuntimeError(
718
+ f"Cannot get channel for queue {queue_name}: no connection available after {max_retries} attempts"
719
+ )
720
+
721
+ except Exception as e:
722
+ if attempt < max_retries - 1:
723
+ logger.warning(
724
+ f"Error getting channel for {queue_name}, retrying: {e}"
725
+ )
726
+ await asyncio.sleep(retry_delay)
727
+ retry_delay *= 2
728
+ else:
729
+ logger.error(
730
+ f"Failed to get channel for {queue_name} after {max_retries} attempts: {e}"
731
+ )
732
+ raise
733
+
734
+ async def _monitor_connection_health(self) -> None:
735
+ """
736
+ Monitor connection health and trigger reconnection if needed.
737
+ This runs as a background task.
738
+ """
739
+ while not self.shutdown_event.is_set():
740
+ try:
741
+ await asyncio.sleep(self.config.connection_health_check_interval)
742
+
743
+ if self.shutdown_event.is_set():
744
+ break
745
+
746
+ # Check connection health
747
+ if not await self._is_connection_healthy():
748
+ logger.warning(
749
+ "Connection health check failed, triggering reconnection"
750
+ )
751
+ if not self.reconnection_in_progress:
752
+ self._trigger_reconnection()
753
+
754
+ except asyncio.CancelledError:
755
+ logger.info("Connection health monitoring cancelled")
756
+ break
757
+ except Exception as e:
758
+ logger.error(f"Error in connection health monitoring: {e}")
759
+ await asyncio.sleep(5) # Wait before retrying
760
+
761
+ async def _is_connection_healthy(self) -> bool:
762
+ """
763
+ Check if the connection is healthy.
764
+ """
765
+ try:
766
+ if self.connection is None or self.connection.is_closed:
767
+ return False
768
+
769
+ # Try to create a temporary channel to test connection
770
+ async with self.connection.channel() as test_channel:
771
+ # If we can create a channel, connection is healthy
772
+ return True
773
+
774
+ except Exception as e:
775
+ logger.debug(f"Connection health check failed: {e}")
776
+ return False
777
+
778
+ def _trigger_reconnection(self) -> None:
779
+ """
780
+ Trigger reconnection process.
781
+ """
782
+ if not self.reconnection_in_progress and not self.shutdown_event.is_set():
783
+ self.reconnection_in_progress = True
784
+ self.connection_healthy = False
785
+ self.reconnection_event.clear()
786
+
787
+ # Start reconnection task
788
+ self.reconnection_task = asyncio.create_task(self._handle_reconnection())
789
+ self.reconnection_task.add_done_callback(self._on_reconnection_done)
790
+
791
+ def _on_reconnection_done(self, task: asyncio.Task[Any]) -> None:
792
+ """
793
+ Handle completion of reconnection task.
794
+ """
795
+ self.reconnection_in_progress = False
796
+ if task.exception():
797
+ logger.error(f"Reconnection task failed: {task.exception()}")
585
798
  else:
799
+ logger.info("Reconnection completed successfully")
800
+
801
+ async def _handle_reconnection(self) -> None:
802
+ """
803
+ Handle the reconnection process with exponential backoff.
804
+ """
805
+ logger.info("Starting reconnection process")
806
+
807
+ # Close existing connection and channels
808
+ await self._cleanup_connection()
809
+
810
+ reconnection_config = self.config.reconnection_backoff_config
811
+ attempt = 0
812
+
813
+ while not self.shutdown_event.is_set():
586
814
  try:
587
- yield channel
588
- finally:
589
- # We don't close the channel here as it's managed by the consumer
590
- pass
815
+ attempt += 1
816
+ logger.info(f"Reconnection attempt {attempt}")
817
+
818
+ # Establish new connection
819
+ self.connection = await self._establish_connection()
820
+ self.connection_healthy = True
821
+
822
+ # Re-establish all consumers
823
+ await self._reestablish_consumers()
824
+
825
+ logger.info("Reconnection successful")
826
+ self.reconnection_event.set()
827
+ return
828
+
829
+ except Exception as e:
830
+ logger.error(f"Reconnection attempt {attempt} failed: {e}")
831
+
832
+ if self.shutdown_event.is_set():
833
+ break
834
+
835
+ # Calculate backoff delay
836
+ delay = reconnection_config.initial_delay * (
837
+ reconnection_config.backoff_factor ** (attempt - 1)
838
+ )
839
+ if reconnection_config.jitter:
840
+ jitter_amount = delay * 0.25
841
+ delay = delay + random.uniform(-jitter_amount, jitter_amount)
842
+ delay = max(delay, 0.1)
843
+
844
+ delay = min(delay, reconnection_config.max_delay)
845
+
846
+ logger.info(f"Retrying reconnection in {delay:.2f} seconds")
847
+ await asyncio.sleep(delay)
848
+
849
+ async def _cleanup_connection(self) -> None:
850
+ """
851
+ Clean up existing connection and channels.
852
+ """
853
+ # Cancel existing consumers
854
+ for queue_name, channel in self.channels.items():
855
+ try:
856
+ if not channel.is_closed:
857
+ # Cancel consumer if we have its tag
858
+ if queue_name in self.consumer_tags:
859
+ try:
860
+ queue = await channel.get_queue(queue_name, ensure=False)
861
+ if queue:
862
+ await queue.cancel(self.consumer_tags[queue_name])
863
+ except Exception as cancel_error:
864
+ logger.warning(
865
+ f"Error cancelling consumer for {queue_name}: {cancel_error}"
866
+ )
867
+ del self.consumer_tags[queue_name]
868
+ except Exception as e:
869
+ logger.warning(f"Error cancelling consumer for {queue_name}: {e}")
870
+
871
+ # Close channels
872
+ for queue_name, channel in self.channels.items():
873
+ try:
874
+ if not channel.is_closed:
875
+ await channel.close()
876
+ except Exception as e:
877
+ logger.warning(f"Error closing channel for {queue_name}: {e}")
878
+
879
+ self.channels.clear()
880
+
881
+ # Close connection
882
+ if self.connection and not self.connection.is_closed:
883
+ try:
884
+ await self.connection.close()
885
+ except Exception as e:
886
+ logger.warning(f"Error closing connection: {e}")
887
+
888
+ self.connection = None
889
+ self.connection_healthy = False
890
+
891
+ async def _reestablish_consumers(self) -> None:
892
+ """
893
+ Re-establish all consumers after reconnection.
894
+ """
895
+ logger.info("Re-establishing consumers after reconnection")
896
+
897
+ # Re-establish message handlers
898
+ for handler in self.message_handler_set:
899
+ queue_name = f"{handler.message_type.MESSAGE_TOPIC}.{handler.instance_callable.__module__}.{handler.instance_callable.__qualname__}"
900
+ try:
901
+ await self._setup_message_handler_consumer(handler)
902
+ logger.info(f"Re-established consumer for {queue_name}")
903
+ except Exception as e:
904
+ logger.error(f"Failed to re-establish consumer for {queue_name}: {e}")
905
+
906
+ # Re-establish scheduled actions
907
+ for scheduled_action in self.scheduled_actions:
908
+ queue_name = f"{scheduled_action.callable.__module__}.{scheduled_action.callable.__qualname__}"
909
+ try:
910
+ await self._setup_scheduled_action_consumer(scheduled_action)
911
+ logger.info(f"Re-established scheduler consumer for {queue_name}")
912
+ except Exception as e:
913
+ logger.error(
914
+ f"Failed to re-establish scheduler consumer for {queue_name}: {e}"
915
+ )
591
916
 
592
917
 
593
918
  def create_message_bus(
@@ -632,6 +957,19 @@ def create_message_bus(
632
957
  max_retries=30, initial_delay=5, max_delay=60.0, backoff_factor=3.0
633
958
  )
634
959
 
960
+ # Parse optional reconnection configuration parameters
961
+ reconnection_backoff_config = RetryConfig(
962
+ max_retries=-1, # Infinite retries for reconnection
963
+ initial_delay=2.0,
964
+ max_delay=120.0,
965
+ backoff_factor=2.0,
966
+ jitter=True,
967
+ )
968
+
969
+ # Parse heartbeat and health check intervals
970
+ connection_heartbeat_interval = 30.0
971
+ connection_health_check_interval = 10.0
972
+
635
973
  # Connection retry config parameters
636
974
  if (
637
975
  "connection_retry_max" in query_params
@@ -698,12 +1036,65 @@ def create_message_bus(
698
1036
  except ValueError:
699
1037
  pass
700
1038
 
1039
+ # Reconnection backoff config parameters
1040
+ if (
1041
+ "reconnection_retry_max" in query_params
1042
+ and query_params["reconnection_retry_max"][0].isdigit()
1043
+ ):
1044
+ reconnection_backoff_config.max_retries = int(
1045
+ query_params["reconnection_retry_max"][0]
1046
+ )
1047
+
1048
+ if "reconnection_retry_delay" in query_params:
1049
+ try:
1050
+ reconnection_backoff_config.initial_delay = float(
1051
+ query_params["reconnection_retry_delay"][0]
1052
+ )
1053
+ except ValueError:
1054
+ pass
1055
+
1056
+ if "reconnection_retry_max_delay" in query_params:
1057
+ try:
1058
+ reconnection_backoff_config.max_delay = float(
1059
+ query_params["reconnection_retry_max_delay"][0]
1060
+ )
1061
+ except ValueError:
1062
+ pass
1063
+
1064
+ if "reconnection_retry_backoff" in query_params:
1065
+ try:
1066
+ reconnection_backoff_config.backoff_factor = float(
1067
+ query_params["reconnection_retry_backoff"][0]
1068
+ )
1069
+ except ValueError:
1070
+ pass
1071
+
1072
+ # Heartbeat and health check intervals
1073
+ if "connection_heartbeat_interval" in query_params:
1074
+ try:
1075
+ connection_heartbeat_interval = float(
1076
+ query_params["connection_heartbeat_interval"][0]
1077
+ )
1078
+ except ValueError:
1079
+ pass
1080
+
1081
+ if "connection_health_check_interval" in query_params:
1082
+ try:
1083
+ connection_health_check_interval = float(
1084
+ query_params["connection_health_check_interval"][0]
1085
+ )
1086
+ except ValueError:
1087
+ pass
1088
+
701
1089
  config = AioPikaWorkerConfig(
702
1090
  url=broker_url,
703
1091
  exchange=exchange,
704
1092
  prefetch_count=prefetch_count,
705
1093
  connection_retry_config=connection_retry_config,
706
1094
  consumer_retry_config=consumer_retry_config,
1095
+ connection_heartbeat_interval=connection_heartbeat_interval,
1096
+ connection_health_check_interval=connection_health_check_interval,
1097
+ reconnection_backoff_config=reconnection_backoff_config,
707
1098
  )
708
1099
 
709
1100
  return AioPikaMicroserviceConsumer(
@@ -754,6 +1145,25 @@ class ScheduledMessageHandlerCallback:
754
1145
  )
755
1146
  return
756
1147
 
1148
+ # Check if connection is healthy before processing
1149
+ if not self.consumer.connection_healthy:
1150
+ logger.warning(
1151
+ f"Connection not healthy, requeuing scheduled message for {self.queue_name}"
1152
+ )
1153
+ try:
1154
+ # Wait briefly for potential reconnection
1155
+ await asyncio.sleep(0.1)
1156
+ if not self.consumer.connection_healthy:
1157
+ # Still not healthy, requeue the message
1158
+ async with self.consumer.get_channel_ctx(self.queue_name):
1159
+ await aio_pika_message.reject(requeue=True)
1160
+ return
1161
+ except Exception as e:
1162
+ logger.error(
1163
+ f"Failed to requeue scheduled message due to connection issues: {e}"
1164
+ )
1165
+ return
1166
+
757
1167
  async with self.consumer.lock:
758
1168
  task = asyncio.create_task(self.handle_message(aio_pika_message))
759
1169
  self.consumer.tasks.add(task)
@@ -789,6 +1199,21 @@ class ScheduledMessageHandlerCallback:
789
1199
  logger.error(f"Failed to requeue message during shutdown: {e}")
790
1200
  return
791
1201
 
1202
+ # Check connection health before processing
1203
+ if not self.consumer.connection_healthy:
1204
+ logger.warning(
1205
+ f"Connection not healthy, requeuing scheduled message for {self.queue_name}"
1206
+ )
1207
+ try:
1208
+ async with self.consumer.get_channel_ctx(self.queue_name):
1209
+ await aio_pika_message.reject(requeue=True)
1210
+ return
1211
+ except Exception as e:
1212
+ logger.error(
1213
+ f"Failed to requeue scheduled message due to connection issues: {e}"
1214
+ )
1215
+ return
1216
+
792
1217
  sig = inspect.signature(self.scheduled_action.callable)
793
1218
  if len(sig.parameters) == 1:
794
1219
 
@@ -832,18 +1257,19 @@ class ScheduledMessageHandlerCallback:
832
1257
  args: tuple[Any, ...],
833
1258
  kwargs: dict[str, Any],
834
1259
  ) -> 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
- ):
1260
+ with provide_shutdown_state(self.consumer.shutdown_state):
1261
+ async with self.consumer.uow_context_provider(
1262
+ AppTransactionContext(
1263
+ controller_member_reflect=scheduled_action.controller_member,
1264
+ transaction_data=SchedulerTransactionData(
1265
+ scheduled_to=datetime.now(UTC),
1266
+ cron_expression=scheduled_action.spec.cron,
1267
+ triggered_at=datetime.now(UTC),
1268
+ ),
1269
+ )
1270
+ ):
845
1271
 
846
- await scheduled_action.callable(*args, **kwargs)
1272
+ await scheduled_action.callable(*args, **kwargs)
847
1273
 
848
1274
 
849
1275
  class MessageHandlerCallback:
@@ -880,6 +1306,23 @@ class MessageHandlerCallback:
880
1306
  logger.error(f"Failed to requeue message during shutdown: {e}")
881
1307
  return
882
1308
 
1309
+ # Check if connection is healthy before processing
1310
+ if not self.consumer.connection_healthy:
1311
+ logger.warning(
1312
+ f"Connection not healthy, requeuing message for {self.queue_name}"
1313
+ )
1314
+ try:
1315
+ # Wait briefly for potential reconnection
1316
+ await asyncio.sleep(0.1)
1317
+ if not self.consumer.connection_healthy:
1318
+ # Still not healthy, requeue the message
1319
+ async with self.consumer.get_channel_ctx(self.queue_name):
1320
+ await aio_pika_message.reject(requeue=True)
1321
+ return
1322
+ except Exception as e:
1323
+ logger.error(f"Failed to requeue message due to connection issues: {e}")
1324
+ return
1325
+
883
1326
  async with self.consumer.lock:
884
1327
  task = asyncio.create_task(self.handle_message(aio_pika_message))
885
1328
  self.consumer.tasks.add(task)
@@ -944,8 +1387,11 @@ class MessageHandlerCallback:
944
1387
  f"dead-lettering: {str(exception)}"
945
1388
  )
946
1389
  # Dead-letter the message after max retries
947
- async with self.consumer.get_channel_ctx(self.queue_name):
948
- await aio_pika_message.reject(requeue=False)
1390
+ try:
1391
+ async with self.consumer.get_channel_ctx(self.queue_name):
1392
+ await aio_pika_message.reject(requeue=False)
1393
+ except Exception as e:
1394
+ logger.error(f"Failed to dead-letter message {message_id}: {e}")
949
1395
  return
950
1396
 
951
1397
  # Calculate delay for this retry attempt
@@ -981,29 +1427,33 @@ class MessageHandlerCallback:
981
1427
  )
982
1428
 
983
1429
  # Acknowledge the current message since we'll handle retry ourselves
984
- async with self.consumer.get_channel_ctx(self.queue_name):
985
- await aio_pika_message.ack()
1430
+ try:
1431
+ async with self.consumer.get_channel_ctx(self.queue_name):
1432
+ await aio_pika_message.ack()
1433
+ except Exception as e:
1434
+ logger.error(
1435
+ f"Failed to acknowledge message {message_id} for retry: {e}"
1436
+ )
986
1437
  return
987
1438
 
988
1439
  # 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
- )
1440
+ try:
1441
+ async with self.consumer.get_channel_ctx(self.queue_name):
1442
+ await aio_pika_message.reject(requeue=requeue)
1443
+ if requeue:
1444
+ logger.info(
1445
+ f"Message {message_id} ({self.queue_name}) requeued for immediate retry"
1446
+ )
1447
+ else:
1448
+ logger.info(
1449
+ f"Message {message_id} ({self.queue_name}) rejected without requeue"
1450
+ )
1451
+ except Exception as e:
1452
+ logger.error(f"Failed to reject message {message_id}: {e}")
999
1453
 
1000
- except RuntimeError as e:
1001
- logger.error(
1002
- f"Error rejecting message {message_id} ({self.queue_name}): {e}"
1003
- )
1004
1454
  except Exception as e:
1005
1455
  logger.exception(
1006
- f"Unexpected error rejecting message {message_id} ({self.queue_name}): {e}"
1456
+ f"Unexpected error in handle_reject_message for {message_id} ({self.queue_name}): {e}"
1007
1457
  )
1008
1458
 
1009
1459
  async def _delayed_retry(
@@ -1018,7 +1468,7 @@ class MessageHandlerCallback:
1018
1468
 
1019
1469
  Args:
1020
1470
  aio_pika_message: The original message
1021
- delay: Delay in seconds before retry
1471
+ delay: Delay in seconds before retrying
1022
1472
  retry_count: The current retry count (after increment)
1023
1473
  exception: The exception that caused the failure
1024
1474
  """
@@ -1043,28 +1493,46 @@ class MessageHandlerCallback:
1043
1493
  if message_id in self.retry_state:
1044
1494
  del self.retry_state[message_id]
1045
1495
 
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
- )
1496
+ # Republish the message to the same queue with retry logic
1497
+ max_attempts = 3
1498
+ for attempt in range(max_attempts):
1499
+ try:
1500
+ async with self.consumer.get_channel_ctx(
1501
+ self.queue_name
1502
+ ) as channel:
1503
+ exchange = await RabbitmqUtils.get_main_exchange(
1504
+ channel=channel,
1505
+ exchange_name=self.consumer.config.exchange,
1506
+ )
1052
1507
 
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
- )
1508
+ await exchange.publish(
1509
+ aio_pika.Message(
1510
+ body=message_body,
1511
+ headers=headers,
1512
+ message_id=message_id,
1513
+ content_type=aio_pika_message.content_type,
1514
+ content_encoding=aio_pika_message.content_encoding,
1515
+ delivery_mode=aio_pika_message.delivery_mode,
1516
+ ),
1517
+ routing_key=self.routing_key,
1518
+ )
1064
1519
 
1065
- logger.info(
1066
- f"Message {message_id} ({self.queue_name}) republished for retry {retry_count}"
1067
- )
1520
+ logger.info(
1521
+ f"Message {message_id} ({self.queue_name}) republished for retry {retry_count}"
1522
+ )
1523
+ return
1524
+
1525
+ except Exception as e:
1526
+ if attempt < max_attempts - 1:
1527
+ logger.warning(
1528
+ f"Failed to republish message {message_id} (attempt {attempt + 1}): {e}"
1529
+ )
1530
+ await asyncio.sleep(1.0 * (attempt + 1)) # Exponential backoff
1531
+ else:
1532
+ logger.error(
1533
+ f"Failed to republish message {message_id} after {max_attempts} attempts: {e}"
1534
+ )
1535
+ raise
1068
1536
 
1069
1537
  except Exception as e:
1070
1538
  logger.exception(
@@ -1133,83 +1601,92 @@ class MessageHandlerCallback:
1133
1601
  incoming_message_spec = MessageHandler.get_message_incoming(handler)
1134
1602
  assert incoming_message_spec is not None
1135
1603
 
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
- )
1144
- ):
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)))
1168
-
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:
1604
+ with provide_shutdown_state(self.consumer.shutdown_state):
1605
+ async with self.consumer.uow_context_provider(
1606
+ AppTransactionContext(
1607
+ controller_member_reflect=handler_data.controller_member,
1608
+ transaction_data=MessageBusTransactionData(
1609
+ message=builded_message,
1610
+ topic=routing_key,
1611
+ ),
1612
+ )
1613
+ ):
1614
+ ctx: AsyncContextManager[Any]
1615
+ if incoming_message_spec.timeout is not None:
1616
+ ctx = asyncio.timeout(incoming_message_spec.timeout)
1617
+ else:
1618
+ ctx = none_context()
1619
+ async with ctx:
1620
+ try:
1621
+ with provide_bus_message_controller(
1622
+ AioPikaMessageBusController(aio_pika_message)
1623
+ ):
1624
+ await handler(builded_message)
1625
+ if not incoming_message_spec.auto_ack:
1626
+ with suppress(aio_pika.MessageProcessError):
1627
+ # Use channel context for acknowledgement with retry
1628
+ try:
1629
+ async with self.consumer.get_channel_ctx(
1630
+ self.queue_name
1631
+ ):
1632
+ await aio_pika_message.ack()
1633
+ except Exception as ack_error:
1634
+ logger.warning(
1635
+ f"Failed to acknowledge message {aio_pika_message.message_id or 'unknown'}: {ack_error}"
1636
+ )
1637
+ # Message will be redelivered if ack fails, which is acceptable
1638
+ except BaseException as base_exc:
1639
+ # Get message id for logging
1640
+ message_id = aio_pika_message.message_id or str(uuid.uuid4())
1641
+
1642
+ # Extract retry count from headers if available
1643
+ headers = aio_pika_message.headers or {}
1644
+ retry_count = int(str(headers.get("x-retry-count", 0)))
1645
+
1646
+ # Process exception handler if configured
1647
+ if incoming_message_spec.exception_handler is not None:
1648
+ try:
1649
+ incoming_message_spec.exception_handler(base_exc)
1650
+ except Exception as nested_exc:
1651
+ logger.exception(
1652
+ f"Error processing exception handler for message {message_id}: {base_exc} | {nested_exc}"
1653
+ )
1654
+ else:
1174
1655
  logger.exception(
1175
- f"Error processing exception handler for message {message_id}: {base_exc} | {nested_exc}"
1656
+ f"Error processing message {message_id} on topic {routing_key}: {str(base_exc)}"
1176
1657
  )
1177
- else:
1178
- logger.exception(
1179
- f"Error processing message {message_id} on topic {routing_key}: {str(base_exc)}"
1180
- )
1181
1658
 
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:
1197
- # Message processed successfully, log and clean up any retry state
1198
- message_id = aio_pika_message.message_id or str(uuid.uuid4())
1199
- if message_id in self.retry_state:
1200
- del self.retry_state[message_id]
1201
-
1202
- # Log success with retry information if applicable
1203
- headers = aio_pika_message.headers or {}
1204
- if "x-retry-count" in headers:
1205
- 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"
1208
- )
1659
+ # Handle rejection with retry logic
1660
+ if incoming_message_spec.requeue_on_exception:
1661
+ # Use our retry with backoff mechanism
1662
+ await self.handle_reject_message(
1663
+ aio_pika_message,
1664
+ requeue=False, # Don't requeue directly, use our backoff mechanism
1665
+ retry_count=retry_count,
1666
+ exception=base_exc,
1667
+ )
1668
+ else:
1669
+ # Message shouldn't be retried, reject it
1670
+ await self.handle_reject_message(
1671
+ aio_pika_message, requeue=False, exception=base_exc
1672
+ )
1209
1673
  else:
1210
- logger.info(
1211
- f"Message {message_id}#{self.queue_name} processed successfully"
1212
- )
1674
+ # Message processed successfully, log and clean up any retry state
1675
+ message_id = aio_pika_message.message_id or str(uuid.uuid4())
1676
+ if message_id in self.retry_state:
1677
+ del self.retry_state[message_id]
1678
+
1679
+ # Log success with retry information if applicable
1680
+ headers = aio_pika_message.headers or {}
1681
+ if "x-retry-count" in headers:
1682
+ retry_count = int(str(headers.get("x-retry-count", 0)))
1683
+ logger.info(
1684
+ f"Message {message_id}#{self.queue_name} processed successfully after {retry_count} retries"
1685
+ )
1686
+ else:
1687
+ logger.info(
1688
+ f"Message {message_id}#{self.queue_name} processed successfully"
1689
+ )
1213
1690
 
1214
1691
 
1215
1692
  @asynccontextmanager