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/
This commit is contained in:
93
CLAUDE.md
Normal file
93
CLAUDE.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user