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:
59
docs/adr/ADR-001-circuit-breaker-and-retry.md
Normal file
59
docs/adr/ADR-001-circuit-breaker-and-retry.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# ADR-001: Circuit Breaker and Retry via gobreaker and avast/retry-go
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-18
|
||||
|
||||
## Context
|
||||
|
||||
Outbound HTTP calls to external services are subject to transient failures (network blips,
|
||||
brief service restarts) and sustained failures (outages, overloads). Two complementary
|
||||
strategies address these cases:
|
||||
|
||||
- **Retry** recovers from transient failures by re-attempting the request a limited number
|
||||
of times before giving up.
|
||||
- **Circuit breaking** detects sustained failure patterns and stops sending requests to a
|
||||
failing service, giving it time to recover and preventing the caller from accumulating
|
||||
blocked goroutines.
|
||||
|
||||
Implementing both from scratch introduces risk of subtle bugs (backoff arithmetic, state
|
||||
machine transitions). Well-tested, widely adopted libraries are preferable.
|
||||
|
||||
## Decision
|
||||
|
||||
Two external libraries are composed:
|
||||
|
||||
**Retry: `github.com/avast/retry-go/v4`**
|
||||
- Configured via `Config.MaxRetries` and `Config.RetryDelay`.
|
||||
- Uses `retry.BackOffDelay` (exponential backoff) to avoid hammering a failing service.
|
||||
- `retry.LastErrorOnly(true)` ensures only the final error from the retry loop is reported.
|
||||
- Only HTTP 5xx responses trigger a retry. 4xx responses are not retried (they represent
|
||||
caller errors, not server instability).
|
||||
|
||||
**Circuit breaker: `github.com/sony/gobreaker`**
|
||||
- Configured via `Config.CBThreshold` (consecutive failures to trip) and `Config.CBTimeout`
|
||||
(time in open state before transitioning to half-open).
|
||||
- The retry loop runs inside the circuit breaker's `Execute` call. A full retry sequence
|
||||
counts as one attempt from the circuit breaker's perspective only if all retries fail.
|
||||
- When the circuit opens, `Do` returns `xerrors.ErrUnavailable` immediately, without
|
||||
attempting the network call.
|
||||
- State changes are logged via the duck-typed `Logger` interface.
|
||||
|
||||
The nesting order (circuit breaker wraps retry) is intentional: the circuit breaker
|
||||
accumulates failures at the level of "did the request ultimately succeed after retries",
|
||||
not at the level of individual attempts.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Transient failures are handled transparently by the caller.
|
||||
- Sustained outages are detected quickly and the circuit opens, returning fast errors.
|
||||
- Configuration is explicit and environment-variable driven.
|
||||
- Circuit state changes are observable via logs.
|
||||
|
||||
**Negative:**
|
||||
- Retry with backoff increases total latency for failing requests up to
|
||||
`MaxRetries * RetryDelay * (2^MaxRetries - 1)` in the worst case.
|
||||
- The circuit breaker counts only consecutive failures (`ConsecutiveFailures >= CBThreshold`),
|
||||
not a rolling failure rate. Interleaved successes reset the counter.
|
||||
- `gobreaker.ErrOpenState` is wrapped in `xerrors.ErrUnavailable`, so callers must check for
|
||||
this specific code to distinguish circuit-open from normal 503 responses.
|
||||
50
docs/adr/ADR-002-request-id-propagation.md
Normal file
50
docs/adr/ADR-002-request-id-propagation.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# ADR-002: Request ID Propagation via X-Request-ID Header
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-18
|
||||
|
||||
## Context
|
||||
|
||||
In a distributed system, a single inbound request may fan out to multiple downstream service
|
||||
calls. Without a shared correlation identifier, tracing a request across service logs requires
|
||||
matching timestamps or other heuristics. A request ID, propagated as an HTTP header
|
||||
(`X-Request-ID`), lets logs across services be correlated by a single value.
|
||||
|
||||
The `logz` module owns the request ID context key (ADR-003 global: context helpers live with
|
||||
data owners). `httpclient` depends on `logz` and should use its helpers rather than define its
|
||||
own context key.
|
||||
|
||||
## Decision
|
||||
|
||||
Inside the retry function in `Do`, before executing each request attempt, the client reads
|
||||
the request ID from the context using `logz.GetRequestID(req.Context())`. If a non-empty
|
||||
value is present, it is set as the `X-Request-ID` header on the outgoing request:
|
||||
|
||||
```go
|
||||
if id := logz.GetRequestID(req.Context()); id != "" {
|
||||
req.Header.Set("X-Request-ID", id)
|
||||
}
|
||||
```
|
||||
|
||||
The header is set on every retry attempt, not just the first, because the same `*http.Request`
|
||||
object is reused across retries.
|
||||
|
||||
If no request ID is present in the context (the ID is the zero string), the header is not
|
||||
set. This is verified by `TestClient_Do_NoRequestID`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Request IDs flow automatically to downstream services without any caller boilerplate.
|
||||
- Correlation across service boundaries works with no additional middleware.
|
||||
- The integration is testable: `TestClient_Do_InjectsRequestID` verifies end-to-end
|
||||
propagation using `logz.WithRequestID` and an `httptest.Server`.
|
||||
|
||||
**Negative:**
|
||||
- `httpclient` takes a direct import dependency on `logz`. This is accepted per ADR-001
|
||||
(global) which permits direct imports between framework modules.
|
||||
- The header name `X-Request-ID` is hardcoded. Projects that use a different header name
|
||||
(e.g. `X-Correlation-ID`) cannot configure this without forking the client.
|
||||
- Header propagation only works when the caller places the request ID in the context via
|
||||
`logz.WithRequestID`. Requests built without a context carrying a request ID will not
|
||||
have the header set.
|
||||
57
docs/adr/ADR-003-generic-dojson-helper.md
Normal file
57
docs/adr/ADR-003-generic-dojson-helper.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# ADR-003: Generic DoJSON[T] Helper for Typed JSON Requests
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-18
|
||||
|
||||
## Context
|
||||
|
||||
Calling an HTTP API and decoding the JSON response into a Go struct involves the same
|
||||
boilerplate in every caller: execute the request, check the status code, read the body,
|
||||
unmarshal into the target type. Without a shared helper, this boilerplate is duplicated across
|
||||
services and each copy is a potential source of inconsistent error handling.
|
||||
|
||||
Prior to generics (Go 1.18), a typed helper required either an `interface{}` argument
|
||||
(losing type safety) or code generation.
|
||||
|
||||
## Decision
|
||||
|
||||
A generic top-level function `DoJSON[T any]` is provided in `helpers.go`:
|
||||
|
||||
```go
|
||||
func DoJSON[T any](ctx context.Context, client Client, req *http.Request) (*T, error)
|
||||
```
|
||||
|
||||
It:
|
||||
1. Attaches `ctx` to the request via `req.WithContext(ctx)`.
|
||||
2. Calls `client.Do(req)` — benefits from retry and circuit breaking.
|
||||
3. Returns `MapStatusToError` for any HTTP 4xx or 5xx response.
|
||||
4. Reads and unmarshals the body into a `T`, returning `*T` on success.
|
||||
5. Wraps read and unmarshal failures in `xerrors.ErrInternal`.
|
||||
|
||||
`MapStatusToError` maps common HTTP status codes to canonical `xerrors` codes:
|
||||
- 404 → `ErrNotFound`
|
||||
- 400 → `ErrInvalidInput`
|
||||
- 401 → `ErrUnauthorized`
|
||||
- 403 → `ErrPermissionDenied`
|
||||
- 409 → `ErrAlreadyExists`
|
||||
- 429 → `ErrUnavailable`
|
||||
- everything else → `ErrInternal`
|
||||
|
||||
`DoJSON` is a free function, not a method, so it works with any value that satisfies the
|
||||
`Client` interface (including mocks).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Callers get a fully typed response with one function call and consistent error semantics.
|
||||
- The generic type parameter means no type assertions at the call site.
|
||||
- `MapStatusToError` is exported and reusable independently of `DoJSON`.
|
||||
|
||||
**Negative:**
|
||||
- `DoJSON` always reads the entire body into memory before unmarshalling. Large response
|
||||
bodies should use `client.Do` directly with streaming JSON decoding.
|
||||
- The function returns `*T` (pointer to decoded value). Callers must nil-check when the
|
||||
response might legitimately be empty (though an empty body would typically produce an
|
||||
unmarshal error).
|
||||
- Response headers are not accessible through `DoJSON`; callers that need headers (e.g. for
|
||||
pagination cursors) must call `client.Do` directly.
|
||||
Reference in New Issue
Block a user