Files
httpclient/CLAUDE.md
Rene Nochebuena 6026ab8a5e feat(httpclient): initial stable release v0.9.0
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/
2026-03-19 13:04:37 +00:00

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 gobreaker Execute call 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 set X-Request-ID on 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 Client implementation. Returns *T with xerrors-typed errors for all failure cases. See ADR-003.
  • Duck-typed Logger: The internal logger field is typed as logz.Logger, the shared interface from the logz module (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 Client instance across logically separate downstream services if you need independent circuit breaker state per service. Create one Client per downstream with a distinct Config.Name.
  • Do not use DoJSON for responses that need streaming or where headers must be inspected. Use client.Do directly.
  • Do not catch gobreaker.ErrOpenState directly — it is wrapped in xerrors.ErrUnavailable. Use errors.As(err, &xe) and check xe.Code() == xerrors.ErrUnavailable.
  • Do not set MaxRetries to a high value without considering total latency: retries use exponential backoff (retry.BackOffDelay).

Testing Notes

  • Tests use net/http/httptest.NewServer to create local HTTP servers. No external calls.
  • TestClient_Do_Retry5xx verifies that MaxRetries: 3 results in multiple server calls.
  • TestClient_Do_InjectsRequestID uses logz.WithRequestID to place a request ID in context and confirms the server receives it as X-Request-ID.
  • TestMapStatusToError_AllCodes covers every mapped status code exhaustively.
  • compliance_test.go (package httpclient_test) asserts New(...) satisfies Client at compile time.