fix(httpclient): retry timed-out requests, stop replaying POST and PATCH - #157
Merged
Merged
Conversation
The 30 second timeout was set on the http.Client, which bounds the whole retry loop including the waits between attempts. A request that failed by timing out had already spent the entire budget, so the first backoff returned at once with an expired context and the loop exited at "(attempt: 1) sleeping". isRetryableError opted into retrying context.DeadlineExceeded, but a timeout could never actually be retried. The retry transport now owns a per attempt deadline and the client carries none. The deadline covers reading the response body, so the attempt's cancel func is handed to the body's Close rather than fired when RoundTrip returns. Cancelling it earlier breaks every response the caller reads. Retries also ran for every method, with the body rebuilt from GetBody, so a create that PostHog committed but failed to acknowledge was sent a second time. That leaves an insight or dashboard Terraform never records, invisible to plan and never cleaned up. POST and PATCH are now retried only on 429, which PostHog returns before processing the request. GET, HEAD, PUT and DELETE are safe to send twice and are unchanged. Two timing assertions in the retry tests used a 10% band around a wall clock duration that can only overshoot. They failed locally often enough to hide this work, so they now assert the property being tested instead. Fixes #156
Every RetryConfig producer had to remember to set AttemptTimeout, because the zero value meant no deadline and the http.Client no longer carries one. NoRetryConfig was already hand-patched to compensate, which is the sign the invariant belongs to NewRetryTransport. It now fills in the default, so a config built without the field gets a deadline instead of waiting forever. Also drops the MaxRetries == 0 fast path, which the loop already covers by returning after the first attempt, and folds the two cleanup branches in attempt into one. Test handlers that stalled now return as soon as the client gives up. They slept the full duration, so httptest's Close waited it out after the attempt deadline had already closed the connection, costing the package about 1.4s.
Removing http.Client.Timeout also removed the "(Client.Timeout exceeded while awaiting headers)" suffix it appended, leaving a bare "context deadline exceeded" that reads like the user cancelled their own apply. The attempt now names its own timeout, and the terminal error carries the attempt number that the other two exits of RoundTrip already carried. A reader can tell at a glance whether the request was retried: GET -> (attempt: 4) attempt timed out after 30s: context deadline exceeded POST -> (attempt: 1) attempt timed out after 30s: context deadline exceeded Also drops the AttemptTimeout guard in attempt. NewRetryTransport already normalizes the field, so the guard was a second, contradicting answer to what a zero value means, and it guarded a caller that does not exist.
…g transports Three follow-ups from review of the retry fix. The decision not to replay a POST was silent. Returning the response looked the same as running out of attempts, so TF_LOG=DEBUG could not tell an operator whether the provider had retried at all. Both decisions now log, and giving up carries the reason. NewRetryTransport filled in AttemptTimeout but nothing else, so a hand-built RetryConfig came out with a deadline and a zero backoff, retrying with no spacing between attempts. It now fills every tunable whose zero value is not a real choice, which also makes the RetryableStatusCodes doc comment true. WithNoRetry and WithRetryConfig wrapped whatever transport was installed, which for NewDefaultClient is already a RetryTransport. The inner one kept retrying, so WithNoRetry did not disable retries. Once the MaxRetries == 0 passthrough went away, the stacked pair also reproduced this issue's own bug: the outer per-attempt deadline had to cover the inner loop end to end. They now replace rather than stack.
…ackoff A caller context that expired during an attempt produced "(attempt: 1) sleeping: context deadline exceeded" — the exact message this change set out to remove. isRetryableError treats DeadlineExceeded as retryable without asking whose deadline it was, so the loop decided to retry and then died in the backoff. The decision now checks the caller's context first and returns its error directly. Retrying and giving up are now one function that returns the decision and the reason together, rather than a bool the caller turned back into a string. The two had already drifted: a POST killed by the caller's context was logged as "POST is not safe to replay", which is not what happened. A dial that never connected is now replayed for any method. It wrote no bytes, so unlike a response-side failure it cannot have applied a create twice. This is the cheap half of the "was anything sent" rule; the httptrace version stays out. Also pins the error text in a test, since it is the part an operator reads, and corrects the changelog: a timed-out create is not retried either, and the per-request ceiling is spent again on each redirect hop.
Review follow-ups, none of them behavior changes beyond the log wording. The comment on retryDecision still named shouldRetry, which the previous commit deleted. The give-up branch wrote one log field three times through a nested conditional; the wording is now chosen by a function that a test can read. That also fixes the message an operator sees on a 404 during drift detection, which said "giving up on request" as if something had failed repeatedly. Unwrapping a stacked retry transport moves into the middleware package, next to the constructor that builds the composition, instead of the client options reaching into RetryTransport.Base. The last tight-band timing assertion in the retry tests joins its two siblings on floor-plus-ceiling, and the changelog names POST and PATCH rather than "creates and updates", since PUT and DELETE are updates that do retry.
…dy helps The issue's third comment points out that net/http's transport already answers "is this safe to send again", so mirroring it beats inventing a rule. Reading it turned up one thing worth copying and two worth writing down. Request.isReplayable treats Idempotency-Key and X-Idempotency-Key as making a POST replayable. That is now honoured here for the same reason, and it is the extension point for any endpoint that later becomes safe to retry. The rule itself cannot be reused: isReplayable, nothingWrittenError and shouldRetryRequest are all unexported and reachable only from inside the transport, which sits below this middleware. Two differences from it are deliberate and now documented next to the code. The transport's replayable set stops at GET, HEAD, OPTIONS and TRACE, while this keeps PUT and DELETE, which RFC 9110 defines as idempotent. And the "nothing was written to the wire" case is left to the transport, which already replays any method when it can prove it, but only on a reused connection; a failed dial is the case it refuses, which is the one handled here. A test pins that cloning per attempt keeps GetBody, since without it the transport below cannot do that replay.
…landed Two review findings. RoundTrip returned as soon as the response headers arrived, leaving the body for the caller to read. A body that stalled therefore timed out outside the retry loop, where it could be neither retried nor attributed, which contradicted the promise that a timed-out read is retried. The attempt now reads the body itself, so a stall fails the attempt like any other timeout. Every response here was already read whole by doRequest, so this buffers nothing the caller was not about to buffer, and it returns the connection to the pool sooner. The cancel-on-close wrapper is gone with it. When a create is deliberately not sent again, the reason reached only TF_LOG=DEBUG while the returned error showed just the timeout. An operator reading it had no way to know the write may already have landed, and re-running could leave a second resource Terraform never records. The error now carries it: (attempt: 1) not retried, POST may already have been applied: attempt timed out after 30s: context deadline exceeded
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #156. The reporter's diagnosis is correct on both counts, and this takes the per-attempt-context direction they proposed.
1. A timed-out request could never be retried
Timeout: 30ssat on thehttp.Clientwhile the retry loop lived in itsTransport.Client.Timeoutbounds the wholeDo()— every attempt and every backoff — so a request that failed by timing out had already spent the budget. The first backoff returned instantly with an expired context and the loop died at(attempt: 1) sleeping: context deadline exceeded.flowchart LR subgraph before["Before — one 30s budget for the whole loop"] B1["attempt 1<br/>burns all 30s"] --> B2["backoff<br/>context already expired"] --> B3["give up"] end subgraph after["After — 30s per attempt"] A1["attempt 1 · own 30s"] --> A2["backoff<br/>caller's context, alive"] --> A3["attempt 2 · own 30s"] --> A4["… up to 4"] endThe transport now owns the deadline (
AttemptTimeout, 30s); the client carries none.The part worth reviewing closely: that deadline has to cover reading the response body, which happens after
RoundTripreturns. Cancelling whenRoundTripreturns — as the issue's sketch does — kills the body for every successful request. The cancel func is handed to the body'sClose()instead.TestRetryTransport_ResponseBodyOutlivesRoundTripfails withcontext canceledagainst the naive placement.2. Retries replayed
POSTandPATCH, duplicating resourcesThe decision read the error or status alone, with no method check, while
client.gosetsreq.GetBody. A create PostHog committed but failed to acknowledge was sent again, leaving a resource Terraform never records and will not clean up. The rule now:GET HEAD PUT DELETE OPTIONS TRACE) or anIdempotency-Keyheader — replay.POST/PATCH— surface the error rather than risk a duplicate.On
net/httpalready doing thisRaised in the issue's third comment.
Request.isReplayable,nothingWrittenErrorandshouldRetryRequestare unexported and sit below this middleware, so the rule cannot literally be reused — but itsIdempotency-Keyhandling now is.The useful half:
shouldRetryRequestchecks "nothing written" before the method, so the transport already replays aPOSTon a dead pooled connection whenGetBodyis set. Verified by closing a pooled connection under it — one extra dial, server saw thePOSTonce. The layers do not overlap: it refuses fresh-connection failures, which is exactly where our dial case starts.One correction for the record — the comment gives the stdlib's replayable set as including
PUT/DELETE; it does not (GET HEAD OPTIONS TRACE). We are deliberately wider, per RFC 9110, because unlike the transport's invisible connection-level retry ours is explicit and logged.Also fixed, and why they belong here
WithNoRetry/WithRetryConfigstacked rather than replaced the retry transport, soWithNoRetrydid not disable retries — and removing the redundantMaxRetries == 0passthrough made that stacked pair reproduce this issue's own shape.NewRetryTransportnow fills every tunable whose zero is not a meaningful choice. Retry and give-up decisions log at debug level with a reason.Not included
Provider-schema knobs (
request_timeout,max_retries) — a workaround for the bug being fixed. Fullhttptracebyte-tracking.Retry-Afteris still clamped toMaxBackoff(pre-existing), and there is no total request budget: the ~2 minute per-request ceiling is in the upgrade note, and it is spent again per page and per redirect hop.Test plan
go test -cover ./internal/...green over repeated consecutive runs and under-race;golangci-lintreports 0 issues. Middleware coverage 91% → 96.9%.Both defects were reproduced before being fixed: the pre-fix shape gives the reporter's exact symptom, and reverting the method rule fails all six duplicate-producing cases.
TestRetryTransport_WithBodymoved fromPOSTtoPUT— it was the test asserting the buggy behaviour.Two pre-existing flaky timing assertions were rewritten as floor-plus-ceiling (
TestSleep/completes_after_durationfailed 12 of 20 runs onmain); they were blocking the local gate.