// 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 }