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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user