Skip to content

Refuse with 406, retry cut-off uploads, and log arrival lag - #32

Merged
brocla merged 2 commits into
mainfrom
webhook-retry-and-lag
Oct 29, 2026
Merged

brocla merged 2 commits into
mainfrom
webhook-retry-and-lag

Conversation

@brocla

@brocla brocla commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Why

On 2026-10-28 two emails to iwk@ took 10 and 21 minutes to get replies. Mailgun's logs showed its forwards to the webhook timing out at 10 seconds. One attempt reached FileMill mid-upload and was logged only as 400 malformed request body, and the log had no way to show how long any email took to arrive.

Mailgun's docs also show that 400 doesn't do what the code assumed. Mailgun retries every non-200 response except 406, so our "don't retry" 400s were being resent for 8 hours.

Changes

  • 406 for permanent refusals: an oversize attachment, a request body over the limit, and a body that arrived whole but won't parse.
  • 500 for cut-off uploads: a body shorter than its Content-Length, or one whose read failed. Mailgun retries these. The handler reads the rest of the body before deciding, because the parser stops at the first error and an unread remainder would otherwise look like a cut-off.
  • The parse error is logged, e.g. request body incomplete: got 181234 of 363441 bytes: unexpected EOF or malformed request body: <parser error>.
  • The accepted line now ends with sent= posted= lag=. These are the email's Date header in UTC, Mailgun's signed timestamp, and the time from sending to arrival. If Date is missing or won't parse, the line says sent=unknown and the email is still processed.

Testing

  • Nine webhook tests, written before the code: the 406 cases, both cut-off cases, and the three arrival-time cases. The existing oversize test now expects 406.
  • go vet ./... and go test ./... pass.

After deploy

  • Check the first real accepted: line to confirm Mailgun posts Date the way this expects. A sent=unknown there means it doesn't.

🤖 Generated with Claude Code

Kevin Brown and others added 2 commits October 27, 2026 23:36
Mailgun retries every non-200 response except 406, on a fixed schedule
over 8 hours. The handler treated 400 as "don't retry", so a malformed
or oversize request was in fact resent for hours. Permanent refusals
now return 406.

A body that fails to parse is now classified instead of reported as a
bare "malformed request body". The rest of the body is drained first,
since the parser stops at the first error: a body that came up short of
its Content-Length, or whose read failed, was cut off in transit and
gets 500 so Mailgun retries it; one that arrived whole and still would
not parse gets 406. Either way the underlying error is logged. On
2026-10-28 Mailgun gave up on an upload after its 10-second timeout and
the log could not say why the body was rejected.

The accepted line now ends with sent= (the email's Date header, in UTC),
posted= (Mailgun's signed timestamp) and lag= (from sending to arrival
here), so a slow-reply report can be measured from our own log. A
missing or unparseable Date logs sent=unknown and never blocks the
email. Date is read from the top-level field, falling back to the
message-headers JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Go's multipart parser takes a clean end-of-stream between parts as the
end of the form, so a body cut short there parses without error. The
completeness check ran only after a parse failure, so such a body went
on to the signature check; the cut-off test caught it about a quarter
of the time, depending on map order in the test helper.

The body is now drained and judged after every parse. A real dropped
connection reads as unexpected EOF, which the parser never accepts, so
production was not exposed, but the byte count is the reliable signal.
The test now tries every cut point instead of one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brocla
brocla merged commit db14426 into main Oct 29, 2026
1 check passed
@brocla
brocla deleted the webhook-retry-and-lag branch October 29, 2026 03:34
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