package httpclient import ( "errors" "fmt" "net" "net/http" "strconv" "time" retry "github.com/avast/retry-go/v4" "github.com/sony/gobreaker" "code.nochebuena.dev/einherjar/contracts/logging" corelogz "code.nochebuena.dev/einherjar/core/logz" "code.nochebuena.dev/einherjar/core/xerrors" ) // Compile-time interface verification (I-8 / CT-5). var _ Provider = (*httpClientImpl)(nil) // New returns a Provider with the given configuration. func New(logger logging.Logger, cfg Config) Provider { name := cfg.Name if name == "" { name = "http" } cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{ Name: name, Timeout: cfg.CBTimeout, ReadyToTrip: func(counts gobreaker.Counts) bool { return counts.ConsecutiveFailures >= cfg.CBThreshold }, OnStateChange: func(n string, from, to gobreaker.State) { logger.Warn("httpclient: circuit breaker state change", "name", n, "from", from.String(), "to", to.String()) }, }) return &httpClientImpl{ client: &http.Client{ Timeout: cfg.Timeout, Transport: &http.Transport{ DialContext: (&net.Dialer{Timeout: cfg.DialTimeout}).DialContext, }, }, logger: logger, cfg: cfg, cb: cb, } } // NewWithDefaults returns a Provider with sensible defaults. func NewWithDefaults(logger logging.Logger) Provider { return New(logger, DefaultConfig()) } type httpClientImpl struct { client *http.Client logger logging.Logger cfg Config cb *gobreaker.CircuitBreaker } func (c *httpClientImpl) Do(req *http.Request) (*http.Response, error) { var resp *http.Response result, err := c.cb.Execute(func() (any, error) { var innerErr error var retryAfterDelay time.Duration retryErr := retry.Do( func() error { if id := corelogz.GetRequestID(req.Context()); id != "" { req.Header.Set("X-Request-ID", id) } start := time.Now() resp, innerErr = c.client.Do(req) latency := time.Since(start) if innerErr != nil { c.logger.Debug("httpclient: request error", "err", innerErr, "url", req.URL.String()) return innerErr } c.logger.Info("httpclient: request completed", "method", req.Method, "url", req.URL.String(), "status", resp.StatusCode, "latency", latency.String(), ) if resp.StatusCode == http.StatusTooManyRequests { if ra := resp.Header.Get("Retry-After"); ra != "" { if secs, err := strconv.Atoi(ra); err == nil { retryAfterDelay = time.Duration(secs) * time.Second } } return fmt.Errorf("rate limited: %d", resp.StatusCode) } if resp.StatusCode >= 500 { return fmt.Errorf("server error: %d", resp.StatusCode) } return nil }, retry.Attempts(c.cfg.MaxRetries), retry.Delay(c.cfg.RetryDelay), retry.DelayType(func(n uint, err error, config *retry.Config) time.Duration { if retryAfterDelay > 0 { d := retryAfterDelay retryAfterDelay = 0 return d } return retry.BackOffDelay(n, err, config) }), retry.LastErrorOnly(true), ) return resp, retryErr }) if err != nil { if errors.Is(err, gobreaker.ErrOpenState) { return nil, xerrors.New(xerrors.ErrUnavailable, "external service unavailable (circuit open)").WithError(err) } if resp != nil { return nil, MapStatusToError(resp.StatusCode, "external API error") } return nil, xerrors.New(xerrors.ErrInternal, "network or timeout error").WithError(err) } if r, ok := result.(*http.Response); ok { return r, nil } return resp, nil }