Skip to content

fix(httpclient): retry timed-out requests, stop replaying POST and PATCH - #157

Merged
vdekrijger merged 8 commits into
mainfrom
retry-per-attempt-timeout-and-idempotency
Sep 1, 2026
Merged

fix(httpclient): retry timed-out requests, stop replaying POST and PATCH#157
vdekrijger merged 8 commits into
mainfrom
retry-per-attempt-timeout-and-idempotency

Conversation

@vdekrijger

@vdekrijger vdekrijger commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

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: 30s sat on the http.Client while the retry loop lived in its Transport. Client.Timeout bounds the whole Do() — 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"]
  end
Loading

The 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 RoundTrip returns. Cancelling when RoundTrip returns — as the issue's sketch does — kills the body for every successful request. The cancel func is handed to the body's Close() instead. TestRetryTransport_ResponseBodyOutlivesRoundTrip fails with context canceled against the naive placement.

2. Retries replayed POST and PATCH, duplicating resources

The decision read the error or status alone, with no method check, while client.go sets req.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:

  • 429 — replay any method; it was rejected before processing.
  • Idempotent method (GET HEAD PUT DELETE OPTIONS TRACE) or an Idempotency-Key header — replay.
  • Failed dial — replay any method; nothing was written.
  • Anything else on POST/PATCH — surface the error rather than risk a duplicate.

On net/http already doing this

Raised in the issue's third comment. Request.isReplayable, nothingWrittenError and shouldRetryRequest are unexported and sit below this middleware, so the rule cannot literally be reused — but its Idempotency-Key handling now is.

The useful half: shouldRetryRequest checks "nothing written" before the method, so the transport already replays a POST on a dead pooled connection when GetBody is set. Verified by closing a pooled connection under it — one extra dial, server saw the POST once. 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/WithRetryConfig stacked rather than replaced the retry transport, so WithNoRetry did not disable retries — and removing the redundant MaxRetries == 0 passthrough made that stacked pair reproduce this issue's own shape. NewRetryTransport now 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. Full httptrace byte-tracking. Retry-After is still clamped to MaxBackoff (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-lint reports 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_WithBody moved from POST to PUT — it was the test asserting the buggy behaviour.

Two pre-existing flaky timing assertions were rewritten as floor-plus-ceiling (TestSleep/completes_after_duration failed 12 of 20 runs on main); they were blocking the local gate.

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
@vdekrijger
vdekrijger marked this pull request as ready for review September 1, 2026 07:58
@vdekrijger
vdekrijger merged commit f0888cc into main Sep 1, 2026
14 checks passed
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.

Retry middleware runs inside http.Client.Timeout, so a timed-out request can never be retried

1 participant