Resilient HTTP client with circuit breaking, exponential-backoff retry, X-Request-ID propagation, and a generic typed JSON helper. What's included: - Client interface with Do(req) method; New(logger, cfg) and NewWithDefaults(logger) constructors - Config struct with env-tag support for timeout, dial timeout, retry, and circuit breaker parameters - Retry via avast/retry-go/v4 with BackOffDelay; triggers only on network errors and HTTP 5xx - Circuit breaker via sony/gobreaker wrapping the full retry loop; open circuit → xerrors.ErrUnavailable - X-Request-ID header propagated automatically from context via logz.GetRequestID on every attempt - DoJSON[T](ctx, client, req) generic helper for typed JSON request/response with xerrors error mapping - MapStatusToError(code, msg) exported function mapping HTTP status codes to xerrors types Tested-via: todo-api POC integration Reviewed-against: docs/adr/
3.9 KiB
3.9 KiB
httpclient
Resilient HTTP client with automatic retry, circuit breaking, request-ID propagation, and typed JSON helpers.
Purpose
Wraps net/http with two reliability layers (retry and circuit breaker) and convenience
helpers for outbound service calls. Designed for use by application services and higher-tier
modules that need to call external HTTP APIs.
Tier & Dependencies
Tier 2 — depends on:
code.nochebuena.dev/go/logz(Tier 1)code.nochebuena.dev/go/xerrors(Tier 0)github.com/sony/gobreaker(circuit breaker)github.com/avast/retry-go/v4(retry with backoff)
Does not depend on health or launcher — it has no lifecycle. It is a stateless
constructor, not a component.
Key Design Decisions
- Circuit breaker wraps retry: The
gobreakerExecutecall contains the entire retry loop. The circuit breaker sees one failure per fully-exhausted retry sequence, not per individual attempt. See ADR-001. - Only 5xx triggers retry: 4xx responses represent caller errors and are not retried. Only network errors and HTTP 5xx responses enter the retry loop.
- Request ID propagation:
logz.GetRequestID(ctx)is called inside the retry function to setX-Request-IDon each outbound attempt. Header is omitted if no ID is in context. See ADR-002. - Generic DoJSON[T]: A free function rather than a method so it works with any
Clientimplementation. Returns*Twith xerrors-typed errors for all failure cases. See ADR-003. - Duck-typed Logger: The internal
loggerfield is typed aslogz.Logger, the shared interface from thelogzmodule (ADR-001 global pattern). - MapStatusToError: Exported. Can be used independently to convert an HTTP status code already in hand to a canonical xerrors error code.
Patterns
Basic usage:
client := httpclient.NewWithDefaults(logger)
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)
Typed JSON response:
type UserResponse struct { ID string; Name string }
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
user, err := httpclient.DoJSON[UserResponse](ctx, client, req)
Named client with custom config (e.g. for a specific downstream):
client := httpclient.New(logger, httpclient.Config{
Name: "payments-api",
Timeout: 10 * time.Second,
MaxRetries: 2,
CBThreshold: 5,
CBTimeout: 30 * time.Second,
})
Request ID propagation (automatic when context carries the ID):
ctx = logz.WithRequestID(ctx, requestID)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
resp, err := client.Do(req) // X-Request-ID header set automatically
What to Avoid
- Do not share a single
Clientinstance across logically separate downstream services if you need independent circuit breaker state per service. Create oneClientper downstream with a distinctConfig.Name. - Do not use
DoJSONfor responses that need streaming or where headers must be inspected. Useclient.Dodirectly. - Do not catch
gobreaker.ErrOpenStatedirectly — it is wrapped inxerrors.ErrUnavailable. Useerrors.As(err, &xe)and checkxe.Code() == xerrors.ErrUnavailable. - Do not set
MaxRetriesto a high value without considering total latency: retries use exponential backoff (retry.BackOffDelay).
Testing Notes
- Tests use
net/http/httptest.NewServerto create local HTTP servers. No external calls. TestClient_Do_Retry5xxverifies thatMaxRetries: 3results in multiple server calls.TestClient_Do_InjectsRequestIDuseslogz.WithRequestIDto place a request ID in context and confirms the server receives it asX-Request-ID.TestMapStatusToError_AllCodescovers every mapped status code exhaustively.compliance_test.go(packagehttpclient_test) assertsNew(...)satisfiesClientat compile time.