Files
httpclient/CLAUDE.md

94 lines
3.9 KiB
Markdown
Raw Permalink Normal View History

# 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.