Retry transport-level send failures instead of dropping events#1
Retry transport-level send failures instead of dropping events#1sirsova-readdle wants to merge 1 commit into
Conversation
A connection reset (or any transport failure) makes http.Client.Do return a non-timeout *url.Error with no response, which fell through to the unknown-error path and dropped every event instead of retrying. Route any *url.Error to the retry path so these transient failures are retried with backoff, matching the timeout handling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| // Retry any such request up to MaxRetries rather than dropping the events. | ||
| // net.Error.Temporary is not used to narrow this: it is deprecated and, for | ||
| // the "connection reset by peer" read we need to retry, reports false. | ||
| case isURLErr || responseStatus == http.StatusRequestTimeout || responseStatus == http.StatusInternalServerError: |
There was a problem hiding this comment.
connection reset by peer happens while reading the response — the request was likely already ingested by Amplitude, so retrying re-sends those events. Dedup relies on InsertID (insert_id,omitempty); if the caller (piper $identify events) doesn't set it, this double-counts. Worth confirming InsertID is populated.
There was a problem hiding this comment.
InsertID is always populated: the caller passes empty, but the SDK's ContextPlugin (added by NewClient) assigns a UUID in the before-phase, before the event enters storage. Retries re-send the same stored StorageEvent (before-plugins don't re-run), so the InsertID is stable and Amplitude dedupes. This is also the exact mechanism the pre-existing timeout/500 retries already rely on — this PR only routes one more error class into it.
Problem
Transient network failures cause Amplitude events to be silently dropped, raising a false alert each time. Observed in prod (piper,
\$identify):http.Client.Doreturns a*url.Errorfor any request that never completed at the HTTP level (connection reset, EOF, refused, DNS, TLS). For a connection reset,Timeout()isfalseand there is no response, soresponseStatusis unset. The switch inProcessonly routed to the retry path (processTimeout) for timeouts or 408/500, so a reset fell through toprocessUnknownError→ all events go to the callback, none to retry → the plugin never callsstorage.ReturnBackand the events are lost.Fix
Broaden the retry branch so any transport-level failure (
isURLErr) is retried with backoff, not only timeouts. A server-side HTTP error (4xx/5xx) returns a response and anilerror, so it is never a*url.Error;isURLErr == truereliably means "the request never reached a response" — exactly the class worth retrying. Retries are bounded byMaxRetries, after which events go to the callback as before, so nothing is dropped silently.Note:
net.Error.Temporary()is deliberately not used to narrow this — it is deprecated since Go 1.18 and, verified against the real error, returnsfalseforread: connection reset by peer, so it would leave the bug unfixed.Tests
Added
TestTransportError_Retried: a*url.ErrorwithTimeout() == falseand no status (simulating connection reset) is routed toEventsForRetrywithRetryCountincremented andRetryAtin the future. Existing timeout behavior is unchanged.go test ./...andgo vet ./...pass.Ref: BKD-461
Follow-up (sparkback)
After this merges, sparkback's
v2/go.modreplace github.com/amplitude/analytics-go => github.com/readdle/analytics-go v0.0.0-20231117134130-464d41bda376must be re-pinned to the new commit hash to pick up the fix.Note
Medium Risk
Changes event delivery failure handling for all transport errors; bounded by MaxRetries but broadens what gets retried (DNS, TLS, refused, etc.), which is intended but affects production analytics reliability.
Overview
Amplitude destination no longer treats non-timeout transport failures (e.g. connection reset by peer) as permanent errors that send all events to the callback.
The
Processswitch now routes any*url.Errorfromhttp.Client.Dointo the same retry path as timeouts and 408/500, using existingMaxRetriesand backoff viaprocessTimeout. HTTP responses with status codes still classify by status; only failures before a response count as transport errors.Adds
TestTransportError_Retriedwith a simulated connection-reset*url.Error(Timeout() == false, no status) to assert events land inEventsForRetrywith incrementedRetryCountand scheduledRetryAt.Reviewed by Cursor Bugbot for commit 90208fc. Bugbot is set up for automated code reviews on this repo. Configure here.