Guides
Errors and retries
Every provider fails differently. MailingKit translates all of it into one hierarchy, retries what is safe to retry, and makes sure the same event never sends the same email twice.
One error hierarchy
MailingKitError
├── ConfigurationError
├── ValidationError bad address, header injection, too large...
│ ├── InvalidAddressError
│ └── MessageTooLargeError
├── TemplateError
│ ├── TemplateNotFoundError
│ ├── TemplateDataError
│ └── TemplateRenderError
└── ProviderError .retryable .provider .status_code
├── AuthenticationError
├── RateLimitError .retry_after
├── RecipientRejectedError
├── TemporaryProviderError
└── PermanentProviderErrorCatch as broadly or as narrowly as you need:
from mailingkit import MailingKitError, ProviderError, ValidationError
try:
await mail.send_template("receipt", data, to=customer.email)
except ValidationError:
... # our bug or bad input: do not retry, fix the data
except ProviderError as error:
if error.retryable:
... # still failing after MailingKit's own retries: queue it for later
else:
... # rejected for good: alert someone
The original provider exception is kept as __cause__ for debugging. Error messages
never include credentials or message content.
Automatic retries
Errors marked retryable (timeouts, dropped connections, 4xx SMTP replies, 5xx HTTP
responses, rate limits) are retried inside the call with exponential backoff and full jitter.
The default is three attempts. On a rate limit, the provider's Retry-After is used.
from mailingkit import NO_RETRY, MailingKit, RetryPolicy
mail = MailingKit.from_env(retry=RetryPolicy(max_attempts=5, initial_delay=1, max_delay=20))
fast_fail = MailingKit.from_env(retry=NO_RETRY)Short retries only
These retries happen while your code waits, so they are deliberately short. Retrying for minutes or hours belongs in a queue. Background sending is planned for MailingKit 0.2.
Idempotency
Requests get retried, workers crash and restart, users double-click. Pass a key that identifies the business event, and MailingKit sends at most once per key:
await mail.send_template(
ORDER_SHIPPED,
data,
to=customer.email,
idempotency_key=f"order-shipped:{order.id}",
)- A repeat returns the first
SendResultwithduplicate=True; nothing is sent. - Concurrent sends with the same key in one process are serialized, so only one goes out.
- Providers that support keys natively, such as Resend, receive the key too.
- Keys are remembered for 24 hours by default (
idempotency_ttl=).
The default store lives in process memory. When several processes or machines send with the same keys, give MailingKit a shared store. It is a two-method protocol:
class RedisIdempotencyStore:
def __init__(self, redis):
self.redis = redis
async def get(self, key):
raw = await self.redis.get(f"mailingkit:{key}")
return pickle.loads(raw) if raw else None
async def set(self, key, result, ttl):
await self.redis.set(f"mailingkit:{key}", pickle.dumps(result), ex=int(ttl))
mail = MailingKit.from_env(idempotency_store=RedisIdempotencyStore(redis))Hooks
Hooks observe every send, for metrics, tracing or alerts. They can be sync or async:
def record(event):
metrics.increment(event.name, tags={"provider": event.provider, "template": event.template})
mail = MailingKit.from_env(hooks=[record])| Event | When |
|---|---|
mail.sent | The provider accepted the message. event.result holds the SendResult. |
mail.retry | An attempt failed and will be retried. event.error holds the error. |
mail.failed | The send failed for good. |
mail.duplicate | An idempotency key was reused; nothing was sent. |
Events carry ids, provider and template names and a recipient count, never addresses or content, so they are safe to forward to any monitoring system. A hook that raises is logged and never breaks sending.