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

text
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
    └── PermanentProviderError

Catch as broadly or as narrowly as you need:

python
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.

python
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:

python
await mail.send_template(
    ORDER_SHIPPED,
    data,
    to=customer.email,
    idempotency_key=f"order-shipped:{order.id}",
)
  • A repeat returns the first SendResult with duplicate=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:

python
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:

python
def record(event):
    metrics.increment(event.name, tags={"provider": event.provider, "template": event.template})

mail = MailingKit.from_env(hooks=[record])
EventWhen
mail.sentThe provider accepted the message. event.result holds the SendResult.
mail.retryAn attempt failed and will be retried. event.error holds the error.
mail.failedThe send failed for good.
mail.duplicateAn 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.