Coverage for src/lexigram/notification/mailer/retrying_mailer.py: 30%

43 statements  

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

1"""RetryingMailer — exponential back-off retry decorator for any MailerProtocol.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6from typing import TYPE_CHECKING 

7import uuid 

8 

9from lexigram.contracts.mailer.errors import MailerError 

10from lexigram.contracts.mailer.types import EmailMessage, MessageDeliveryReceipt 

11from lexigram.logging import get_logger 

12from lexigram.result import Err 

13 

14if TYPE_CHECKING: 

15 from lexigram.contracts.core.result import Result 

16 from lexigram.contracts.mailer.protocols import MailerProtocol 

17 from lexigram.contracts.notification.delivery import DeliveryStoreProtocol 

18 

19logger = get_logger(__name__) 

20 

21 

22class RetryingMailer: 

23 """A decorator that wraps any :class:`~lexigram.contracts.mailer.protocols.MailerProtocol` 

24 with exponential back-off retry and persistent delivery tracking. 

25 

26 Each send is assigned a stable ``delivery_id`` (UUID4). Every attempt is 

27 recorded via :class:`~lexigram.contracts.notification.delivery.DeliveryStoreProtocol` 

28 so that delivery history is preserved for auditing and dead-letter inspection. 

29 

30 Retry behaviour: 

31 - Retries only on *raised* exceptions (infrastructure failures: SMTP timeouts, 

32 network errors, auth failures). These are the recoverable transient failures 

33 the retry pattern targets. 

34 - ``Err(MailerError)`` returns from the inner mailer are *not* retried — they 

35 represent expected terminal delivery failures (bounced, rejected) per the 

36 ``MailerProtocol`` contract. 

37 - After exhausting all attempts the final failure is logged, recorded as 

38 ``mark_failed(final=True)``, and returned as ``Err(MailerError)`` — never 

39 raised — to prevent cascading failures in notification flows. 

40 

41 Args: 

42 inner: The underlying :class:`~lexigram.contracts.mailer.protocols.MailerProtocol` 

43 implementation to delegate to. 

44 delivery_store: Persistent store for delivery attempt tracking. 

45 max_attempts: Maximum number of send attempts. Defaults to ``3``. 

46 base_delay: Seconds to wait before the first retry. Each subsequent 

47 retry doubles the delay (exponential back-off). Defaults to ``1.0``. 

48 """ 

49 

50 def __init__( 

51 self, 

52 inner: MailerProtocol, 

53 delivery_store: DeliveryStoreProtocol, 

54 max_attempts: int = 3, 

55 base_delay: float = 1.0, 

56 ) -> None: 

57 self._inner = inner 

58 self._delivery_store = delivery_store 

59 self._max_attempts = max_attempts 

60 self._base_delay = base_delay 

61 

62 async def send( 

63 self, 

64 message: EmailMessage, 

65 ) -> Result[MessageDeliveryReceipt, MailerError]: 

66 """Send an email with exponential back-off retry. 

67 

68 Conforms to :class:`~lexigram.contracts.mailer.protocols.MailerProtocol`. 

69 

70 Args: 

71 message: The fully-formed email message to deliver. 

72 

73 Returns: 

74 ``Ok(MessageDeliveryReceipt)`` on acceptance by the backend. 

75 ``Err(MailerError)`` when all attempts are exhausted or the inner 

76 mailer returns a terminal delivery failure. 

77 """ 

78 delivery_id = str(uuid.uuid4()) 

79 recipient = ", ".join(message.to) 

80 last_exc: Exception | None = None 

81 

82 for attempt in range(1, self._max_attempts + 1): 

83 await self._delivery_store.record_attempt( 

84 delivery_id=delivery_id, 

85 recipient=recipient, 

86 subject=message.subject, 

87 attempt_number=attempt, 

88 ) 

89 try: 

90 result: Result[ 

91 MessageDeliveryReceipt, MailerError 

92 ] = await self._inner.send(message) 

93 except Exception as exc: 

94 last_exc = exc 

95 logger.warning( 

96 "mail_attempt_failed", 

97 delivery_id=delivery_id, 

98 recipient=recipient, 

99 attempt=attempt, 

100 max_attempts=self._max_attempts, 

101 error=str(exc), 

102 ) 

103 if attempt < self._max_attempts: 

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

105 await asyncio.sleep(delay) 

106 continue 

107 

108 # Inner returned a Result — no exception raised. 

109 if result.is_ok(): 

110 await self._delivery_store.mark_delivered(delivery_id) 

111 logger.info( 

112 "mail_delivered", 

113 delivery_id=delivery_id, 

114 recipient=recipient, 

115 attempt=attempt, 

116 ) 

117 return result 

118 

119 # Err(MailerError): expected terminal failure — do not retry. 

120 mailer_err = result.unwrap_err() 

121 await self._delivery_store.mark_failed( 

122 delivery_id=delivery_id, 

123 reason=str(mailer_err), 

124 final=True, 

125 ) 

126 logger.warning( 

127 "mail_delivery_rejected", 

128 delivery_id=delivery_id, 

129 recipient=recipient, 

130 attempt=attempt, 

131 reason=str(mailer_err), 

132 ) 

133 return result 

134 

135 # All attempts exhausted via infrastructure exceptions. 

136 reason = str(last_exc) if last_exc else "unknown" 

137 await self._delivery_store.mark_failed( 

138 delivery_id=delivery_id, 

139 reason=reason, 

140 final=True, 

141 ) 

142 logger.error( 

143 "mail_delivery_failed", 

144 delivery_id=delivery_id, 

145 recipient=recipient, 

146 total_attempts=self._max_attempts, 

147 reason=reason, 

148 ) 

149 return Err( 

150 MailerError( 

151 f"Delivery failed after {self._max_attempts} attempt(s): {reason}", 

152 backend="retrying", 

153 ) 

154 ) 

155 

156 

157__all__ = ["RetryingMailer"]