# 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:** ```go client := httpclient.NewWithDefaults(logger) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) resp, err := client.Do(req) ``` **Typed JSON response:** ```go 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):** ```go 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):** ```go 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.