Fix sentinel error aliasing, hot-path allocations, and resource leaks

- Deduplicate sentinel errors: httpx.ErrNoHealthy, ErrCircuitOpen, and
  ErrRetryExhausted are now aliases to the canonical sub-package values
  so errors.Is works across package boundaries
- Retry transport returns ErrRetryExhausted only when all attempts are
  actually exhausted, not on early policy exit
- Balancer: pre-parse endpoint URLs at construction, replace req.Clone
  with cheap shallow struct copy to avoid per-request allocations
- Circuit breaker: Load before LoadOrStore to avoid allocating a Breaker
  on every request for known hosts
- Health checker: drain response body before close for connection reuse,
  probe endpoints concurrently, run initial probe synchronously in Start
- Client: add Close() to shut down health checker goroutine, propagate
  URL resolution errors instead of silently discarding them
- MockClock: fix lock ordering in Reset (clock.mu before t.mu), fix
  timer slice compaction to avoid backing-array aliasing, extract
  fireExpired to deduplicate Advance/Set
This commit is contained in:
2026-03-20 15:21:32 +03:00
parent f9a05f5c57
commit 5cfd1a7400
8 changed files with 155 additions and 48 deletions

View File

@@ -1,6 +1,8 @@
package retry
import (
"errors"
"fmt"
"io"
"net/http"
"time"
@@ -8,6 +10,10 @@ import (
"git.codelab.vc/pkg/httpx/middleware"
)
// ErrRetryExhausted is returned when all retry attempts have been exhausted
// and the last attempt also failed.
var ErrRetryExhausted = errors.New("httpx: all retry attempts exhausted")
// Policy decides whether a failed request should be retried.
type Policy interface {
// ShouldRetry reports whether the request should be retried. The extra
@@ -28,6 +34,7 @@ func Transport(opts ...Option) middleware.Middleware {
return middleware.RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
var resp *http.Response
var err error
var exhausted bool
for attempt := range cfg.maxAttempts {
// For retries (attempt > 0), restore the request body.
@@ -48,6 +55,7 @@ func Transport(opts ...Option) middleware.Middleware {
// Last attempt — return whatever we got.
if attempt == cfg.maxAttempts-1 {
exhausted = true
break
}
@@ -85,6 +93,10 @@ func Transport(opts ...Option) middleware.Middleware {
}
}
// Wrap with ErrRetryExhausted only when all attempts were used.
if exhausted && err != nil {
err = fmt.Errorf("%w: %w", ErrRetryExhausted, err)
}
return resp, err
})
}