Bearer для /metrics (EVOBGP_METRICS_TOKEN); rate limit /v1/auth/config; constant-time agent secret; OTel stub; Playwright smoke; HTTP_PROXY note; checklist обновлён. Co-authored-by: Cursor <cursoragent@cursor.com>
96 lines
2.6 KiB
Go
96 lines
2.6 KiB
Go
// Package httpclient provides shared HTTP clients and retry helpers for outbound calls.
|
|
package httpclient
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"evobgp/internal/observability"
|
|
)
|
|
|
|
const DefaultTimeout = 45 * time.Second
|
|
|
|
// New returns an HTTP client with timeout and tuned idle connection pooling.
|
|
// Standard proxy env (HTTP_PROXY / HTTPS_PROXY / NO_PROXY) is honored via the cloned DefaultTransport.
|
|
func New(timeout time.Duration) *http.Client {
|
|
if timeout <= 0 {
|
|
timeout = DefaultTimeout
|
|
}
|
|
tr := http.DefaultTransport.(*http.Transport).Clone()
|
|
tr.MaxIdleConns = 100
|
|
tr.MaxIdleConnsPerHost = 10
|
|
return &http.Client{Timeout: timeout, Transport: tr}
|
|
}
|
|
|
|
// DoWithRetry executes hc.Do(req) up to maxAttempts times with linear backoff.
|
|
func DoWithRetry(ctx context.Context, hc *http.Client, req *http.Request, maxAttempts int) (*http.Response, error) {
|
|
if maxAttempts <= 0 {
|
|
maxAttempts = 3
|
|
}
|
|
var lastErr error
|
|
for attempt := 0; attempt < maxAttempts; attempt++ {
|
|
if attempt > 0 {
|
|
wait := time.Duration(attempt) * 2 * time.Second
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(wait):
|
|
}
|
|
if req.GetBody != nil {
|
|
body, err := req.GetBody()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Body = body
|
|
}
|
|
}
|
|
reqClone := req.Clone(ctx)
|
|
resp, err := hc.Do(reqClone)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
if resp.StatusCode >= 500 {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
_ = resp.Body.Close()
|
|
lastErr = fmt.Errorf("httpclient: upstream %s", resp.Status)
|
|
continue
|
|
}
|
|
return resp, nil
|
|
}
|
|
if lastErr != nil {
|
|
return nil, lastErr
|
|
}
|
|
return nil, fmt.Errorf("httpclient: request failed after %d attempts", maxAttempts)
|
|
}
|
|
|
|
// DoWithBreaker applies per-host circuit breaking then retries transient failures.
|
|
func DoWithBreaker(ctx context.Context, hc *http.Client, req *http.Request, maxAttempts int) (*http.Response, error) {
|
|
if req == nil || req.URL == nil {
|
|
return nil, fmt.Errorf("httpclient: nil request")
|
|
}
|
|
host := req.URL.Hostname()
|
|
br := breakerForHost(host)
|
|
if !br.allow() {
|
|
observability.SetUpstreamBreakerOpen(host, true)
|
|
return nil, fmt.Errorf("httpclient: circuit open for %s", host)
|
|
}
|
|
resp, err := DoWithRetry(ctx, hc, req, maxAttempts)
|
|
if err != nil {
|
|
br.recordFailure()
|
|
observability.SetUpstreamBreakerOpen(host, !br.allow())
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode >= 500 {
|
|
br.recordFailure()
|
|
observability.SetUpstreamBreakerOpen(host, !br.allow())
|
|
return resp, nil
|
|
}
|
|
br.recordSuccess()
|
|
observability.SetUpstreamBreakerOpen(host, false)
|
|
return resp, nil
|
|
}
|