DEV Community

BrennanCross2167
BrennanCross2167

Posted on

Logistics Email Failures: API Error Logging and Retry Strategies for Expiring Resets

Short answer: for failed email deliveries from an API, use error logging to preserve the decision context and retry strategies to handle only failures that can plausibly change. Keep the reset template owned by the team that owns the account-security contract, and use a short deadline. A second send must never extend the password-reset token or quietly create a second valid path.

For a logistics operation, this is a practical boundary. A dispatcher may need access before a pickup window closes, but a reset email is still security traffic. The useful question is not “did the API accept my request?” It is “what did we know at each step, and why did we send, retry, suppress, or expire the attempt?”

What does the reset email actually cost to retain?

Start with the billable shape of the work, not with a retry loop. In an illustrative batch of 100,000 reset attempts, one request record per attempt is 100,000 durable records. Retaining a full rendered body, provider response, and repeated payload for every retry can multiply storage and expose more personal data without improving the decision later. The dominant retention term is usually duplicated message evidence, not the small classifier that decides whether a failure is transient.

The change I make is to retain a compact event envelope and a hash of the rendered template, while keeping the reset token out of logs altogether. One attempt can carry an event id, account reference, template revision, request time, expiry time, recipient domain, provider request id when present, normalized error class, and attempt number. The body belongs in a controlled template store with its own access policy; it does not belong in an operational log.

This is a deliberate loss. If an incident needs the exact body sent to one recipient, a hash cannot reconstruct it. The trade is acceptable when the template store is versioned and access is audited. Keeping every payload in the hot log is easier during the first incident and harder during the next privacy review.

The same envelope makes retry accounting cheap and explicit:

from dataclasses import dataclass
from datetime import datetime
from typing import Optional


@dataclass(frozen=True)
class DeliveryEvent:
    event_id: str
    account_ref: str
    template_revision: str
    recipient_domain: str
    created_at: datetime
    expires_at: datetime
    attempt: int
    outcome: str
    error_class: Optional[str]
    provider_request_id: Optional[str]
    body_sha256: str
Enter fullscreen mode Exit fullscreen mode

The envelope is not a success metric. It is the evidence needed to explain the state transition. Keep retention long enough for the security and operations teams to investigate a reset window, then delete or aggregate it according to the organization’s retention policy.

Keep it boring.

How should error logging guide retry strategies for failed email?

The API response is only one signal. A timeout after the request leaves an ambiguous state: the message may have been accepted even though the client did not receive the response. Retrying immediately can produce two emails. A durable idempotency key, or an equivalent deduplication record at the sending boundary, is therefore part of the retry design rather than an optional convenience.

Classify outcomes by whether the cause can change without changing the recipient or template. A connection timeout, a temporary DNS problem, a rate-limit response, or an SMTP 4.x enhanced status generally belongs in a bounded transient path. A malformed address, an authentication failure, a policy rejection, or an SMTP 5.x status generally belongs in a terminal path. The exact meaning still comes from the sending API and its documented error fields; do not infer it from a human-readable message alone.

HTTP 429 commonly means the caller must respect a limit, while 5xx indicates a server-side failure. Neither status tells you that a retry is safe forever. Honor an explicit Retry-After value when one is supplied, cap the number of attempts, and stop when the reset token expires. Exponential backoff with jitter prevents a fleet of workers from waking at the same instant.

Here is the decision boundary I want in code review. It is intentionally boring.

from dataclasses import dataclass
from datetime import datetime, timedelta
import random


@dataclass(frozen=True)
class RetryDecision:
    retry: bool
    delay_seconds: int
    reason: str


def decide_retry(
    *,
    status_code: int | None,
    smtp_class: str | None,
    attempt: int,
    now: datetime,
    expires_at: datetime,
    retry_after_seconds: int | None = None,
    random_value: float | None = None,
) -> RetryDecision:
    if now >= expires_at:
        return RetryDecision(False, 0, "reset_expired")
    if attempt >= 3:
        return RetryDecision(False, 0, "attempt_limit")

    transient = status_code in {408, 429} or (
        status_code is not None and 500 <= status_code <= 599
    ) or smtp_class == "4"
    if not transient:
        return RetryDecision(False, 0, "terminal_delivery_error")

    base = retry_after_seconds if retry_after_seconds is not None else 2 ** attempt
    jitter = int((random_value if random_value is not None else random.random()) * 3)
    delay = min(base + jitter, 60)
    if now + timedelta(seconds=delay) >= expires_at:
        return RetryDecision(False, 0, "retry_would_outlive_reset")
    return RetryDecision(True, delay, "transient_delivery_error")
Enter fullscreen mode Exit fullscreen mode

The numbers in this example are policy inputs, not universal defaults. Three attempts and a 60-second delay cap may fit a short-lived reset flow; a longer-lived notification needs a different budget. The important properties are visible: no retry after expiry, no unbounded loop, and no second token just because transport was uncertain.

This approach is a poor fit for long-running bulk mail or messages with no meaningful expiry. Those workloads need a separate queue budget, suppression policy, and retention schedule; borrowing a password-reset policy would discard useful delivery opportunities. The limitation is deliberate: a short-lived security flow values a clear stopping point more than maximum eventual delivery, while a newsletter or operational report may reasonably keep trying after this reset window has closed. That is the trade-off.

Choose the policy by message purpose, not by the convenience of sharing one worker configuration.

What should a failed reset attempt leave behind?

Log state transitions, not secrets. At minimum, record queued, submitted, accepted, deferred, delivered when the channel reports it, bounced, rejected, expired, and suppressed. Keep the original provider or SMTP code alongside the normalized class. Normalization supports dashboards; the original value preserves evidence when the classifier turns out to be incomplete.

An event should answer five questions without opening a message body: which account flow was involved, which template revision was selected, which recipient domain was targeted, which attempt made the decision, and which clock caused the next action. Correlation ids should connect the account request, queue job, delivery request, and callback. They should not be the reset token, an email address, or a full provider payload.

I also separate “accepted by the API” from “delivered to the mailbox.” Those are different states. SPF is one part of sender authorization, and the SPF specification itself describes policy evaluation rather than a guarantee that a message reaches an inbox. DKIM and DMARC add other pieces of domain authentication and policy. A passing authentication check cannot turn a deferred or rejected delivery into a success.

Alerting should follow the state machine. A spike in transient failures is an operational signal. A rise in permanent rejects for one recipient domain is a deliverability or data-quality signal. A queue age beyond the reset validity window is a security and usability signal. Putting all three into one “email failed” counter hides the action required.

Who should own the template and the retry policy?

The account-security team should own the meaning of the template: what the user is being asked to do, how long the token is valid, and which link is authoritative. The communications or platform team can own rendering infrastructure, queue mechanics, and delivery telemetry. Ownership of the template revision must travel with the event envelope, so a later retry cannot accidentally render a newer message against an older security decision.

For a logistics password reset, the message should identify the action and its short validity window without putting the secret in a subject line, log field, or analytics label. A worker may retry a delivery attempt, but it should reuse the same security transaction and idempotency key. If the reset has expired, the worker records expired; it does not manufacture a fresh reset as a side effect of a transport retry.

Test this boundary with a small matrix: timeout before response, timeout after apparent acceptance, 429 with Retry-After, SMTP 4.x, SMTP 5.x, malformed recipient, template revision change, and expiry during backoff. Assert both the final state and the retained evidence. The test that only checks for a raised exception misses the duplicate-send case, which is the one users notice immediately.

The least complicated design that remains explainable is a versioned template, a single security transaction, an idempotent delivery boundary, and a bounded state machine. Keep the evidence compact, preserve the original error, and let expiry end the retry path. That gives operations enough context to diagnose delivery without making the log a second copy of every sensitive message.

Further reading

Top comments (0)