Coverage for src/lexigram/notification/delivery/retry.py: 100%

30 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 02:32 +0800

1"""RetryingMailer — wraps a mail backend with exponential backoff retry.""" 

2 

3from __future__ import annotations 

4 

5from typing import TYPE_CHECKING, Any 

6 

7from lexigram.logging import get_logger 

8from lexigram.notification.delivery.exceptions import PermanentDeliveryFailure 

9from lexigram.result import Err, Ok, Result 

10 

11if TYPE_CHECKING: 

12 from lexigram.contracts.notification.delivery import DeliveryStoreProtocol 

13 

14logger = get_logger(__name__) 

15 

16 

17class RetryingMailer: 

18 """Mail delivery wrapper with exponential backoff and async retry scheduling. 

19 

20 Wraps any mail backend (one returning ``Result[Any, Any]``) with a delivery 

21 store that persists state and schedules deferred retries for transient 

22 failures — instead of sleeping in-process. 

23 

24 Args: 

25 backend: The underlying mail backend with an async ``send()`` method 

26 returning ``Result[Any, Any]``. 

27 store: Delivery state store for tracking retry state. 

28 max_retries: Maximum delivery attempts before permanent failure. 

29 base_delay: Base delay in seconds for exponential backoff. 

30 """ 

31 

32 def __init__( 

33 self, 

34 backend: Any, 

35 store: DeliveryStoreProtocol, 

36 max_retries: int = 3, 

37 base_delay: float = 60.0, 

38 ) -> None: 

39 self._backend = backend 

40 self._store = store 

41 self._max_retries = max_retries 

42 self._base_delay = base_delay 

43 

44 async def send(self, message: Any) -> Result[str, PermanentDeliveryFailure]: 

45 """Send a message, scheduling a retry on transient failure. 

46 

47 Delegates to the backend. On ``Ok``, marks delivered. On ``Err``, 

48 increments the retry counter: if attempts are below ``max_retries`` 

49 a deferred retry is scheduled via the store; otherwise the delivery 

50 is permanently failed. 

51 

52 Args: 

53 message: Message to send (passed directly to ``backend.send()``). 

54 

55 Returns: 

56 ``Ok(delivery_id)`` on success or when a retry has been scheduled. 

57 ``Err(PermanentDeliveryFailure)`` after max retries exhausted. 

58 """ 

59 delivery_id = await self._store.create_pending(message) 

60 

61 send_result = await self._backend.send(message) 

62 

63 if send_result.is_ok(): 

64 await self._store.mark_delivered(delivery_id) 

65 logger.info("mail_delivered", delivery_id=delivery_id) 

66 return Ok(delivery_id) 

67 

68 attempt = await self._store.increment_retry(delivery_id) 

69 error = send_result.unwrap_err() 

70 

71 if attempt < self._max_retries: 

72 delay = self._base_delay * (2 ** (attempt - 1)) 

73 await self._store.schedule_retry(delivery_id, delay) 

74 logger.warning( 

75 "mail_delivery_retry_scheduled", 

76 delivery_id=delivery_id, 

77 attempt=attempt, 

78 delay_seconds=delay, 

79 error=str(error), 

80 ) 

81 return Ok(delivery_id) 

82 

83 await self._store.mark_failed(delivery_id, reason=str(error)) 

84 logger.error( 

85 "mail_delivery_permanently_failed", 

86 delivery_id=delivery_id, 

87 attempts=attempt, 

88 error=str(error), 

89 ) 

90 return Err(PermanentDeliveryFailure(delivery_id)) 

91 

92 

93__all__ = ["RetryingMailer"]