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/
58 lines
2.3 KiB
Markdown
58 lines
2.3 KiB
Markdown
# 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.
|