For failed email API deliveries, error logging is an evidence problem before it is a retry problem. For a developer-tools support queue, record the request, the provider response, the next action, and the final disposition as one traceable event. Retry only failures that are plausibly temporary. Keep permanent failures visible and closed.

Keep it finite.

No replay.

TL;DR: create an immutable delivery-attempt record with a correlation ID, classify the failure before scheduling work, and cap the retry schedule. The useful before/after is small:

  • Before: a 500 from a mail API becomes a generic application error, then an unbounded job loop.
  • After: the contact submission gets an ID, each attempt gets structured evidence, and a bounded state machine decides retry, quarantine, or close.

That shape supports compliance review without pretending that a successful API request proves inbox delivery.

What evidence should a failed contact form leave behind?

Start at the boundary where your application accepts the form. Generate a request ID there. Store the destination queue, a hash or reference for the submitted content, the consent or notification state required by your policy, and the time the routing decision was made. Do not put the full message body, access token, or arbitrary customer text in ordinary logs.

Then record every delivery attempt separately. A useful record has an event name, correlation ID, attempt number, timestamp, transport outcome, normalized error class, and a redacted provider code or response category. It also needs the worker version and the policy decision that followed. Those fields answer different questions: did the app accept the form, did it ask the mail API to send, and why did the worker try again?

The diagram in words is: form submission points to a routing decision; the decision points to an attempt ledger; each ledger entry points to either a scheduled retry or a terminal review record. Logs help you search. Metrics help you see the shape. The ledger supplies the evidence.

A delivery API returning success is one transition, not the end of the story. SMTP sender policy is also part of the surrounding system. SPF, for example, defines a way for a domain owner to publish which hosts are authorized to use that domain in the SMTP envelope. That is a configuration fact worth capturing in an incident record, but it does not turn an API acknowledgment into proof that a recipient read a message.

How should error logging shape retry strategies for failed email delivery?

Classify by meaning, not by HTTP status alone. A timeout, connection reset, rate limit, or explicitly temporary upstream response can justify a retry. An invalid address, rejected sender identity, policy rejection, or malformed request usually needs correction or human review. A 500 might be transient, but blindly replaying it can create duplicates when the remote system accepted the message before the response was lost.

Here is a deliberately generic worker policy. The endpoint is pseudonymous, the response shape is an application contract, and the retry count is finite.

type DeliveryClass = "temporary" | "permanent" | "ambiguous"; type NextStep = "retry" | "quarantine" | "close"; type AttemptResult = { class: DeliveryClass; providerCode?: string; detail?: string; }; function chooseNextStep(result: AttemptResult, attempt: number): NextStep { const maxAttempts = 4; if (result.class === "permanent") return "close"; if (result.class === "ambiguous") return "quarantine"; if (attempt >= maxAttempts) return "quarantine"; return "retry"; } function retryDelayMs(attempt: number): number { const base = 30_000; const cap = 15 * 60_000; return Math.min(cap, base * 2 ** Math.max(0, attempt - 1)); } 

The important choice is the ambiguous branch. If the client timed out after sending bytes, another attempt may duplicate a support ticket. Quarantine lets an operator reconcile the delivery ledger with the downstream system, or use an idempotency key if the API contract supports one. It is slower than an automatic retry. It is also easier to explain during a review.

That is the trade-off.

Use jitter when multiple workers may retry together. Persist the next-attempt time and the decision before releasing the job. A process restart must not reset the attempt counter. Otherwise a short outage can turn into a queue that never reaches its terminal state.

In the sample schedule, the first delays are 30 seconds, 60 seconds, 2 minutes, and 4 minutes. Those are policy values, not a promise about any particular API. The useful detail is that an operator can read the ledger and see exactly why the next attempt is due, then change the policy deliberately when the queue's service objective changes.

Which logs and metrics catch the real failure?

Logs should be structured and boring. Emit one event when the form is accepted, one when an attempt starts, one when an attempt ends, and one when a terminal decision is made. Keep the same correlation ID across all four. Put error details in fields, not in a sentence assembled from untrusted input.

Metrics should distinguish accepted submissions from attempted sends and terminal outcomes. A rising accepted count with a flat attempt_started count means the worker path is stuck. A rising ambiguous count points to timeouts or weak delivery acknowledgment semantics. A rising permanent count points to address quality, sender policy, or routing data. Those signals are more actionable than a single “email failed” counter.

Alert on a sustained change in the ratio of terminal failures to accepted forms, plus queue age and oldest-attempt age. A one-minute spike can be noise. A queue whose oldest item keeps aging needs an operator even if the API error rate looks ordinary. Keep alert payloads free of message content and recipient addresses; the correlation ID should be enough to find the restricted record.

What should deployment and testing prove?

Test classification with a table of recorded response fixtures. Include a timeout after request transmission, a rate limit, an invalid recipient, a sender-policy rejection, and a malformed request. Assert both the next state and the audit event. The test should prove that a permanent failure is not retried and that an ambiguous result is not silently replayed.

Run a small end-to-end test against a controlled mailbox or test transport, but keep it separate from the compliance ledger. A green test message shows that one path worked at one time. It cannot establish that every production submission was routed correctly.

During deployment, ship the schema for attempt records before shipping the worker that writes new fields. Make dashboards tolerate an absent field for one rollout. This is a mundane compatibility detail, yet it prevents an observability change from hiding the very failures it was meant to expose.

Is bounded retry too conservative for support mail?

Usually, the cost of waiting is visible, while the cost of duplicate support tickets is paid later by a human. Four attempts is an example policy, not a universal constant. Choose the cap from the queue’s service objective, the remote API’s documented semantics, and the amount of review your team can handle. Record that rationale next to the policy.

What if the team wants every failed message retried automatically? Ask which failure classes it means. Temporary failures can use backoff. Permanent failures need data or configuration changes. Ambiguous failures need reconciliation. One queue can hold all three, but one retry rule cannot serve all three safely.

This evidence-first design is a poor fit for disposable, non-audited notifications where a duplicate has no operational consequence. A simpler one-shot send with a coarse failure metric may be enough there. A support queue with compliance obligations has a different burden: it must explain what happened to each submission.

Limitation: this method cannot prove that a recipient read the message, and it cannot repair a bad sender policy or invalid address. It makes the boundary visible so the next decision belongs to the team that owns configuration, data quality, or human review.

The final review should be able to follow one contact form from intake to disposition without opening application source code. That is the standard I would use: evidence first, bounded work second, and a clear terminal state every time.

Sources