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/
2.3 KiB
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:
func DoJSON[T any](ctx context.Context, client Client, req *http.Request) (*T, error)
It:
- Attaches
ctxto the request viareq.WithContext(ctx). - Calls
client.Do(req)— benefits from retry and circuit breaking. - Returns
MapStatusToErrorfor any HTTP 4xx or 5xx response. - Reads and unmarshals the body into a
T, returning*Ton success. - 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.
MapStatusToErroris exported and reusable independently ofDoJSON.
Negative:
DoJSONalways reads the entire body into memory before unmarshalling. Large response bodies should useclient.Dodirectly 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 callclient.Dodirectly.