package httpclient import ( "errors" "fmt" "net" "net/http" "time" retry "github.com/avast/retry-go/v4" "github.com/sony/gobreaker" "code.nochebuena.dev/go/logz" "code.nochebuena.dev/go/xerrors" ) // Client executes HTTP requests with automatic retry and circuit breaking. type Client interface { Do(req *http.Request) (*http.Response, error) } // Config holds configuration for the HTTP client. type Config struct { // Name identifies this client in logs and circuit breaker metrics. Name string `env:"HTTP_CLIENT_NAME" envDefault:"http"` Timeout time.Duration `env:"HTTP_TIMEOUT" envDefault:"30s"` DialTimeout time.Duration `env:"HTTP_DIAL_TIMEOUT" envDefault:"5s"` MaxRetries uint `env:"HTTP_MAX_RETRIES" envDefault:"3"` RetryDelay time.Duration `env:"HTTP_RETRY_DELAY" envDefault:"1s"` CBThreshold uint32 `env:"HTTP_CB_THRESHOLD" envDefault:"10"` CBTimeout time.Duration `env:"HTTP_CB_TIMEOUT" envDefault:"1m"` } // DefaultConfig returns a Config with sensible defaults. func DefaultConfig() Config { return Config{ Name: "http", Timeout: 30 * time.Second, DialTimeout: 5 * time.Second, MaxRetries: 3, RetryDelay: 1 * time.Second, CBThreshold: 10, CBTimeout: 1 * time.Minute, } } type httpClient struct { client *http.Client logger logz.Logger cfg Config cb *gobreaker.CircuitBreaker } // New returns a Client with the given configuration. func New(logger logz.Logger, cfg Config) Client { 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 >= uint32(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 &httpClient{ 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 Client with sensible defaults. Name defaults to "http". func NewWithDefaults(logger logz.Logger) Client { return New(logger, DefaultConfig()) } func (c *httpClient) Do(req *http.Request) (*http.Response, error) { var resp *http.Response result, err := c.cb.Execute(func() (any, error) { var innerErr error retryErr := retry.Do( func() error { if id := logz.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 >= 500 { return fmt.Errorf("server error: %d", resp.StatusCode) } return nil }, retry.Attempts(c.cfg.MaxRetries), retry.Delay(c.cfg.RetryDelay), retry.DelayType(retry.BackOffDelay), 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 }