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
« 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."""
3from __future__ import annotations
5import asyncio
6from typing import TYPE_CHECKING
7import uuid
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
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
19logger = get_logger(__name__)
22class RetryingMailer:
23 """A decorator that wraps any :class:`~lexigram.contracts.mailer.protocols.MailerProtocol`
24 with exponential back-off retry and persistent delivery tracking.
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.
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.
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 """
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
62 async def send(
63 self,
64 message: EmailMessage,
65 ) -> Result[MessageDeliveryReceipt, MailerError]:
66 """Send an email with exponential back-off retry.
68 Conforms to :class:`~lexigram.contracts.mailer.protocols.MailerProtocol`.
70 Args:
71 message: The fully-formed email message to deliver.
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
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
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
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
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 )
157__all__ = ["RetryingMailer"]