The examples in this repository are intentionally minimal — they send one email and report success or failure. For a production sender (contact forms, password resets, notifications) that runs unattended, it’s worth retrying on transient failures instead of dropping the message:
4xx response codes (e.g. 421 Service not available, 450
Mailbox busy) mean “try again later” — this is also how a
rate limit rejection
usually surfaces.5xx response codes (e.g. 550 No such user) are permanent —
retrying an unchanged message will fail again. Don’t retry these; surface
the error instead.Retry a handful of times with an increasing delay, plus a small random
jitter so multiple failed sends don’t all retry at the exact same instant.
Reference implementation in Python, built on
python-zerosmtp.py:
import random
import time
from smtplib import SMTPResponseException, SMTPServerDisconnected
def send_with_retry(send_fn, max_attempts=4, base_delay=2.0):
"""Call send_fn() and retry on transient (4xx) SMTP failures.
Permanent (5xx) errors and non-SMTP exceptions are raised immediately.
"""
for attempt in range(1, max_attempts + 1):
try:
return send_fn()
except (SMTPResponseException, SMTPServerDisconnected) as e:
is_permanent = isinstance(e, SMTPResponseException) and e.smtp_code >= 500
if is_permanent or attempt == max_attempts:
raise
delay = base_delay * (2 ** (attempt - 1)) + random.uniform(0, 1)
time.sleep(delay)
# Usage:
# send_with_retry(lambda: send_email_via_zerosmtp(**config))
The same shape applies in any language: catch the transient-vs-permanent distinction from your SMTP library’s exception/response code, retry only the transient case a bounded number of times, and always cap the total number of attempts — an unattended sender that retries forever on a permanently-misconfigured address is itself a bug.