Fix retry body replay and jitter panic; drive delays via internal/clock

Gate the retry decision on body rewindability: an idempotent request whose
body cannot be replayed (no GetBody) is now returned as-is instead of looping
with an empty body or surfacing a stale, already-drained response. Guard
ExponentialBackoff against rand.Int64N panicking when delay/2 rounds to zero.
Use internal/clock for inter-attempt delays so retry timing is consistent with
the rest of the codebase and testable without real sleeps.
This commit is contained in:
2026-05-23 13:47:18 +03:00
parent e8c4577c6f
commit 43d3ecfba1
4 changed files with 124 additions and 22 deletions

View File

@@ -10,6 +10,7 @@ import (
"testing"
"time"
"git.codelab.vc/pkg/httpx/internal/clock"
"git.codelab.vc/pkg/httpx/middleware"
)
@@ -229,6 +230,83 @@ func TestTransport(t *testing.T) {
})
}
// TestTransport_BodyNotRewindable verifies that an idempotent request whose
// body cannot be replayed (no GetBody) is returned as-is rather than retried
// with an empty body or a stale, already-drained response.
func TestTransport_BodyNotRewindable(t *testing.T) {
var calls atomic.Int32
rt := Transport(
WithMaxAttempts(3),
WithBackoff(ConstantBackoff(time.Millisecond)),
)(mockTransport(func(req *http.Request) (*http.Response, error) {
calls.Add(1)
io.Copy(io.Discard, req.Body) // a real transport consumes the body
return statusResponse(http.StatusServiceUnavailable), nil
}))
// PUT is idempotent (the policy would retry a 503), but with GetBody unset
// the body cannot be rewound.
req, _ := http.NewRequest(http.MethodPut, "http://example.com", strings.NewReader("data"))
req.GetBody = nil
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil || resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("expected the original 503 response, got %v", resp)
}
if got := calls.Load(); got != 1 {
t.Fatalf("expected exactly 1 call (no rewind retry), got %d", got)
}
}
// TestTransport_InjectedClock verifies that backoff delays are driven by the
// configured clock, so retries are deterministic without real sleeps.
func TestTransport_InjectedClock(t *testing.T) {
clk := clock.Mock(time.Now())
var calls atomic.Int32
rt := Transport(
WithMaxAttempts(2),
WithBackoff(ConstantBackoff(time.Hour)), // would block forever on a real clock
withClock(clk),
)(mockTransport(func(req *http.Request) (*http.Response, error) {
calls.Add(1)
return statusResponse(http.StatusServiceUnavailable), nil
}))
req, _ := http.NewRequest(http.MethodGet, "http://example.com", nil)
done := make(chan struct{})
var resp *http.Response
var err error
go func() {
resp, err = rt.RoundTrip(req)
close(done)
}()
// Drive the backoff via the mock clock. Advancing repeatedly is robust
// against the timer being created slightly after the first attempt.
for {
clk.Advance(time.Hour)
select {
case <-done:
goto finished
case <-time.After(time.Millisecond):
}
}
finished:
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", resp.StatusCode)
}
if got := calls.Load(); got != 2 {
t.Fatalf("expected 2 calls, got %d", got)
}
}
// policyFunc adapts a function into a Policy.
type policyFunc func(int, *http.Request, *http.Response, error) (bool, time.Duration)