Skip to content

Retry transient (4xx) SMTP replies, including on the DATA terminator#20

Open
josephsellers wants to merge 1 commit into
knadh:masterfrom
josephsellers:fix-retry-transient-smtp
Open

Retry transient (4xx) SMTP replies, including on the DATA terminator#20
josephsellers wants to merge 1 commit into
knadh:masterfrom
josephsellers:fix-retry-transient-smtp

Conversation

@josephsellers

Copy link
Copy Markdown

While using this library (via LibreDesk, which relies on SES) I hit intermittent hard send failures that succeeded immediately on a manual retry. The message was:

451 4.4.2 Timeout waiting for data from client.

AWS SES returns this transient reply when a pooled connection's server-side state has gone stale. A retry on a fresh connection always succeeds, but MaxMessageRetries never kicked in for it.

Cause

Two gaps meant the transient reply was classified as non-retriable:

  1. canRetry() only recognised connection-level errors (net.Error, net.OpError, io.EOF). A transient SMTP reply is a *textproto.Error, so canRetry(451) returned false.
  2. In conn.send(), the error from w.Close() (the reply to the DATA terminator ., which is exactly where SES returns the 451) was hardcoded to return false, err, bypassing canRetry entirely.

Change

  • canRetry() now also treats a 4xx *textproto.Error as retriable. 5xx stays permanent, so a rejected recipient / oversized message is still not retried.
  • send() / w.Close() now retries on a 4xx reply, but deliberately not on a connection-level error at that point: after the terminator the server may have accepted the message before the socket dropped, so retrying a raw connection error there could cause duplicate delivery. A 4xx reply is an explicit non-acceptance, so retrying it is safe.
  • returnConn() generalises the existing 421-closes-connection rule (Discard pooled SMTP connections on terminal SMTP errors #19) to all 4xx, so a retry dials a fresh connection instead of reusing the compromised one.

Test

Adds TestCanRetry covering the classification (451/421/450 retriable; 550/552 not; io.EOF/net.OpError retriable; plain error / nil not). It needs no SMTP server. The existing MailHog-backed tests are unchanged.

[Used Claude Code 🤖]

canRetry() only classified connection-level errors (net.Error, net.OpError,
io.EOF) as retriable, and conn.send() hardcoded `return false` for the error
from w.Close() -- the reply to the DATA terminator ("."). As a result, a
transient 4xx reply at end-of-DATA was never retried even with
MaxMessageRetries set, and surfaced to the caller as a hard failure.

This is common with AWS SES, which returns "451 4.4.2 Timeout waiting for
data from client." on a pooled connection whose server-side state has gone
stale; an immediate retry on a fresh connection succeeds.

- canRetry(): also treat a 4xx *textproto.Error as retriable (5xx stays
  permanent, so bad-recipient etc. is not retried).
- send()/w.Close(): retry on a 4xx reply, but NOT on a connection-level
  error there -- after the terminator the server may have accepted the
  message before the socket dropped, so retrying could duplicate delivery.
- returnConn(): generalise the 421-closes-connection rule (knadh#19) to all 4xx,
  so a retry dials a fresh connection rather than reusing the poisoned one.

Adds TestCanRetry covering 4xx/5xx/connection/other classification.

[Used Claude Code 🤖]
josephsellers added a commit to josephsellers/libredesk that referenced this pull request Jul 15, 2026
Outbound sends via AWS SES intermittently failed with "451 4.4.2 Timeout
waiting for data from client." on stale pooled connections. smtppool v1.x
marks all SMTP-conversation errors (including the 4xx reply at the DATA
terminator) as non-retriable, so MaxMessageRetries never fired and the
failure surfaced to the agent, who had to click Retry manually.

Point the dependency at josephsellers/smtppool v1.3.1-libredesk, which
backports the retry-classification fix (transient 4xx -> retry on a fresh
connection; 5xx stays permanent; duplicate-safe at the DATA terminator).

Temporary: upstream PR is knadh/smtppool#20 (against the v2 module line).
Drop this replace once we adopt the released, fixed smtppool.

[Used Claude Code 🤖]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant