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:
2026-03-19 13:04:37 +00:00
commit 6026ab8a5e
16 changed files with 860 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
{
"name": "Go",
"image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie",
"features": {
"ghcr.io/devcontainers-extra/features/claude-code:1": {}
},
"forwardPorts": [],
"postCreateCommand": "go version",
"customizations": {
"vscode": {
"settings": {
"files.autoSave": "afterDelay",
"files.autoSaveDelay": 1000,
"explorer.compactFolders": false,
"explorer.showEmptyFolders": true
},
"extensions": [
"golang.go",
"eamodio.golang-postfix-completion",
"quicktype.quicktype",
"usernamehw.errorlens"
]
}
},
"remoteUser": "vscode"
}

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with go test -c
*.test
# Output of go build
*.out
# Dependency directory
vendor/
# Go workspace file
go.work
go.work.sum
# Environment files
.env
.env.*
# Editor / IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# VCS files
COMMIT.md
RELEASE.md

32
CHANGELOG.md Normal file
View File

@@ -0,0 +1,32 @@
# Changelog
All notable changes to this module will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.0] - 2026-03-18
### Added
- `Client` interface: `Do(req *http.Request) (*http.Response, error)`.
- `Config` struct: fields `Name`, `Timeout` (default `30s`), `DialTimeout` (default `5s`), `MaxRetries` (default `3`), `RetryDelay` (default `1s`), `CBThreshold` (default `10`), `CBTimeout` (default `1m`); settable via `HTTP_*` environment variables.
- `DefaultConfig() Config`: returns a `Config` populated with all default values.
- `New(logger logz.Logger, cfg Config) Client`: constructs a client with a `gobreaker` circuit breaker wrapping an `avast/retry-go` retry loop. The circuit breaker trips after `CBThreshold` consecutive failures and resets after `CBTimeout`.
- `NewWithDefaults(logger logz.Logger) Client`: convenience constructor; equivalent to `New(logger, DefaultConfig())`.
- Retry behaviour: up to `MaxRetries` attempts with exponential backoff (`retry.BackOffDelay`). Only network errors and HTTP 5xx responses are retried; 4xx responses are not.
- Circuit breaker behaviour: the breaker wraps the full retry sequence, so one fully-exhausted retry sequence counts as one failure. State transitions are logged at `Warn` level with `name`, `from`, and `to` fields.
- Request ID propagation: `logz.GetRequestID(ctx)` is called on each attempt; if a request ID is present, it is forwarded as the `X-Request-ID` request header.
- Per-request structured logging: successful requests emit an `Info` log with `method`, `url`, `status`, and `latency` fields; failed individual attempts emit a `Debug` log.
- Error mapping on `Do`: open circuit → `ErrUnavailable`; post-retry HTTP error response → `MapStatusToError`; network/timeout error → `ErrInternal`.
- `DoJSON[T any](ctx context.Context, client Client, req *http.Request) (*T, error)`: generic free function that executes a request and decodes the JSON response body into `T`; returns `*T` on success and a xerrors-typed error for network failures, HTTP 4xx/5xx responses, unreadable bodies, or JSON decode failures.
- `MapStatusToError(code int, msg string) error` (exported): maps HTTP status codes to xerrors codes — `404``ErrNotFound`, `400``ErrInvalidInput`, `401``ErrUnauthorized`, `403``ErrPermissionDenied`, `409``ErrAlreadyExists`, `429``ErrUnavailable`, all others → `ErrInternal`.
- Dial timeout applied via a custom `net.Dialer` on the `http.Transport`, independent of the per-request `Timeout`.
### Design Notes
- The circuit breaker wraps the retry loop rather than individual attempts; a full set of exhausted retries registers as a single failure against the breaker threshold, preventing overly aggressive tripping.
- `DoJSON` is a free generic function rather than a method so it works with any `Client` implementation, including mocks, without requiring a concrete type.
- The module has no lifecycle (no `OnInit`/`OnStart`/`OnStop`) and does not depend on `launcher` or `health`; it is a stateless constructor suitable for use at any tier.
[0.9.0]: https://code.nochebuena.dev/go/httpclient/releases/tag/v0.9.0

93
CLAUDE.md Normal file
View 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.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 NOCHEBUENADEV
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

57
README.md Normal file
View File

@@ -0,0 +1,57 @@
# httpclient
Resilient HTTP client with automatic retry and circuit breaking.
## Install
```
go get code.nochebuena.dev/go/httpclient
```
## Usage
```go
client := httpclient.NewWithDefaults(logger)
// or with custom config:
client = httpclient.New(logger, httpclient.Config{
Name: "payment-api",
MaxRetries: 3,
CBThreshold: 10,
})
resp, err := client.Do(req)
```
## Typed JSON helper
```go
order, err := httpclient.DoJSON[Order](ctx, client, req)
```
## Error mapping
```go
httpclient.MapStatusToError(404, "not found") // → xerrors.ErrNotFound
```
| HTTP status | xerrors code |
|---|---|
| 404 | `ErrNotFound` |
| 400 | `ErrInvalidInput` |
| 401 | `ErrUnauthorized` |
| 403 | `ErrPermissionDenied` |
| 409 | `ErrAlreadyExists` |
| 429 | `ErrUnavailable` |
| 5xx | `ErrInternal` |
## Configuration
| Env var | Default | Description |
|---|---|---|
| `HTTP_CLIENT_NAME` | `http` | Circuit breaker name |
| `HTTP_TIMEOUT` | `30s` | Overall request timeout |
| `HTTP_DIAL_TIMEOUT` | `5s` | TCP dial timeout |
| `HTTP_MAX_RETRIES` | `3` | Retry attempts |
| `HTTP_RETRY_DELAY` | `1s` | Base retry delay |
| `HTTP_CB_THRESHOLD` | `10` | Consecutive failures before open |
| `HTTP_CB_TIMEOUT` | `1m` | Time before half-open retry |

8
compliance_test.go Normal file
View File

@@ -0,0 +1,8 @@
package httpclient_test
import (
"code.nochebuena.dev/go/httpclient"
"code.nochebuena.dev/go/logz"
)
var _ httpclient.Client = httpclient.NewWithDefaults(logz.New(logz.Options{}))

10
doc.go Normal file
View File

@@ -0,0 +1,10 @@
// Package httpclient provides a resilient HTTP client with automatic retry and circuit breaking.
//
// Usage:
//
// client := httpclient.NewWithDefaults(logger)
// resp, err := client.Do(req)
//
// // Typed JSON helper
// order, err := httpclient.DoJSON[Order](ctx, client, req)
package httpclient

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

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

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

10
go.mod Normal file
View File

@@ -0,0 +1,10 @@
module code.nochebuena.dev/go/httpclient
go 1.25
require (
code.nochebuena.dev/go/logz v0.9.0
code.nochebuena.dev/go/xerrors v0.9.0
github.com/avast/retry-go/v4 v4.3.4
github.com/sony/gobreaker v1.0.0
)

25
go.sum Normal file
View File

@@ -0,0 +1,25 @@
code.nochebuena.dev/go/logz v0.9.0 h1:wfV7vtI4V/8ED7Hm31Fbql7Y5iOGrlHN4X8Z5ajTZZE=
code.nochebuena.dev/go/logz v0.9.0/go.mod h1:qODhSbKb+tWE7rdhHLcKweiP5CgwIaWoZxadCT3bQV8=
code.nochebuena.dev/go/xerrors v0.9.0 h1:8wrDto7e44ZW1YPOnT6JrxYXTqnvNuKpAO1/5bcT4TE=
code.nochebuena.dev/go/xerrors v0.9.0/go.mod h1:mtXo7xscBreCB7w7smlBP5Onv8H1HVohCvF0I/VXbAY=
github.com/avast/retry-go/v4 v4.3.4 h1:pHLkL7jvCvP317I8Ge+Km2Yhntv3SdkJm7uekkqbKhM=
github.com/avast/retry-go/v4 v4.3.4/go.mod h1:rv+Nla6Vk3/ilU0H51VHddWHiwimzX66yZ0JT6T+UvE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

57
helpers.go Normal file
View File

@@ -0,0 +1,57 @@
package httpclient
import (
"context"
"encoding/json"
"io"
"net/http"
"code.nochebuena.dev/go/xerrors"
)
// DoJSON executes req and decodes the JSON response body into T.
// Returns a xerrors-typed error for HTTP 4xx/5xx responses.
func DoJSON[T any](ctx context.Context, client Client, req *http.Request) (*T, error) {
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, MapStatusToError(resp.StatusCode, "external API error")
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, xerrors.New(xerrors.ErrInternal, "failed to read response body").WithError(err)
}
var data T
if err := json.Unmarshal(body, &data); err != nil {
return nil, xerrors.New(xerrors.ErrInternal, "failed to decode JSON response").WithError(err)
}
return &data, nil
}
// MapStatusToError maps an HTTP status code to the matching xerrors type.
func MapStatusToError(code int, msg string) error {
switch code {
case http.StatusNotFound:
return xerrors.New(xerrors.ErrNotFound, msg)
case http.StatusBadRequest:
return xerrors.New(xerrors.ErrInvalidInput, msg)
case http.StatusUnauthorized:
return xerrors.New(xerrors.ErrUnauthorized, msg)
case http.StatusForbidden:
return xerrors.New(xerrors.ErrPermissionDenied, msg)
case http.StatusConflict:
return xerrors.New(xerrors.ErrAlreadyExists, msg)
case http.StatusTooManyRequests:
return xerrors.New(xerrors.ErrUnavailable, msg)
default:
return xerrors.New(xerrors.ErrInternal, msg)
}
}

141
httpclient.go Normal file
View File

@@ -0,0 +1,141 @@
package httpclient
import (
"errors"
"fmt"
"net"
"net/http"
"time"
retry "github.com/avast/retry-go/v4"
"github.com/sony/gobreaker"
"code.nochebuena.dev/go/logz"
"code.nochebuena.dev/go/xerrors"
)
// Client executes HTTP requests with automatic retry and circuit breaking.
type Client interface {
Do(req *http.Request) (*http.Response, error)
}
// Config holds configuration for the HTTP client.
type Config struct {
// Name identifies this client in logs and circuit breaker metrics.
Name string `env:"HTTP_CLIENT_NAME" envDefault:"http"`
Timeout time.Duration `env:"HTTP_TIMEOUT" envDefault:"30s"`
DialTimeout time.Duration `env:"HTTP_DIAL_TIMEOUT" envDefault:"5s"`
MaxRetries uint `env:"HTTP_MAX_RETRIES" envDefault:"3"`
RetryDelay time.Duration `env:"HTTP_RETRY_DELAY" envDefault:"1s"`
CBThreshold uint32 `env:"HTTP_CB_THRESHOLD" envDefault:"10"`
CBTimeout time.Duration `env:"HTTP_CB_TIMEOUT" envDefault:"1m"`
}
// DefaultConfig returns a Config with sensible defaults.
func DefaultConfig() Config {
return Config{
Name: "http",
Timeout: 30 * time.Second,
DialTimeout: 5 * time.Second,
MaxRetries: 3,
RetryDelay: 1 * time.Second,
CBThreshold: 10,
CBTimeout: 1 * time.Minute,
}
}
type httpClient struct {
client *http.Client
logger logz.Logger
cfg Config
cb *gobreaker.CircuitBreaker
}
// New returns a Client with the given configuration.
func New(logger logz.Logger, cfg Config) Client {
name := cfg.Name
if name == "" {
name = "http"
}
cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: name,
Timeout: cfg.CBTimeout,
ReadyToTrip: func(counts gobreaker.Counts) bool {
return counts.ConsecutiveFailures >= uint32(cfg.CBThreshold)
},
OnStateChange: func(n string, from, to gobreaker.State) {
logger.Warn("httpclient: circuit breaker state change",
"name", n, "from", from.String(), "to", to.String())
},
})
return &httpClient{
client: &http.Client{
Timeout: cfg.Timeout,
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: cfg.DialTimeout}).DialContext,
},
},
logger: logger,
cfg: cfg,
cb: cb,
}
}
// NewWithDefaults returns a Client with sensible defaults. Name defaults to "http".
func NewWithDefaults(logger logz.Logger) Client {
return New(logger, DefaultConfig())
}
func (c *httpClient) Do(req *http.Request) (*http.Response, error) {
var resp *http.Response
result, err := c.cb.Execute(func() (any, error) {
var innerErr error
retryErr := retry.Do(
func() error {
if id := logz.GetRequestID(req.Context()); id != "" {
req.Header.Set("X-Request-ID", id)
}
start := time.Now()
resp, innerErr = c.client.Do(req)
latency := time.Since(start)
if innerErr != nil {
c.logger.Debug("httpclient: request error",
"err", innerErr, "url", req.URL.String())
return innerErr
}
c.logger.Info("httpclient: request completed",
"method", req.Method,
"url", req.URL.String(),
"status", resp.StatusCode,
"latency", latency.String(),
)
if resp.StatusCode >= 500 {
return fmt.Errorf("server error: %d", resp.StatusCode)
}
return nil
},
retry.Attempts(c.cfg.MaxRetries),
retry.Delay(c.cfg.RetryDelay),
retry.DelayType(retry.BackOffDelay),
retry.LastErrorOnly(true),
)
return resp, retryErr
})
if err != nil {
if errors.Is(err, gobreaker.ErrOpenState) {
return nil, xerrors.New(xerrors.ErrUnavailable,
"external service unavailable (circuit open)").WithError(err)
}
if resp != nil {
return nil, MapStatusToError(resp.StatusCode, "external API error")
}
return nil, xerrors.New(xerrors.ErrInternal, "network or timeout error").WithError(err)
}
if r, ok := result.(*http.Response); ok {
return r, nil
}
return resp, nil
}

176
httpclient_test.go Normal file
View File

@@ -0,0 +1,176 @@
package httpclient
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"code.nochebuena.dev/go/logz"
"code.nochebuena.dev/go/xerrors"
)
func newLogger() logz.Logger { return logz.New(logz.Options{}) }
func TestNew(t *testing.T) {
if New(newLogger(), DefaultConfig()) == nil {
t.Fatal("New returned nil")
}
}
func TestNewWithDefaults(t *testing.T) {
c := New(newLogger(), DefaultConfig())
if c == nil {
t.Fatal("NewWithDefaults returned nil")
}
}
func TestClient_Do_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
client := New(newLogger(), Config{
Name: "test", Timeout: 5 * time.Second, DialTimeout: 2 * time.Second,
MaxRetries: 1, RetryDelay: 10 * time.Millisecond, CBThreshold: 10, CBTimeout: time.Minute,
})
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("want 200, got %d", resp.StatusCode)
}
}
func TestClient_Do_Retry5xx(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
client := New(newLogger(), Config{
Name: "test", Timeout: 5 * time.Second, DialTimeout: 2 * time.Second,
MaxRetries: 3, RetryDelay: 1 * time.Millisecond, CBThreshold: 100, CBTimeout: time.Minute,
})
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
_, err := client.Do(req)
if err == nil {
t.Fatal("expected error after retries")
}
if calls < 2 {
t.Errorf("expected multiple calls, got %d", calls)
}
}
func TestClient_Do_InjectsRequestID(t *testing.T) {
var gotHeader string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHeader = r.Header.Get("X-Request-ID")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
client := New(newLogger(), Config{
Name: "test", Timeout: 5 * time.Second, DialTimeout: 2 * time.Second,
MaxRetries: 1, RetryDelay: time.Millisecond, CBThreshold: 10, CBTimeout: time.Minute,
})
ctx := logz.WithRequestID(t.Context(), "req-123")
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
_, _ = client.Do(req)
if gotHeader != "req-123" {
t.Errorf("want X-Request-ID=req-123, got %q", gotHeader)
}
}
func TestClient_Do_NoRequestID(t *testing.T) {
var gotHeader string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHeader = r.Header.Get("X-Request-ID")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
client := New(newLogger(), Config{
Name: "test", Timeout: 5 * time.Second, DialTimeout: 2 * time.Second,
MaxRetries: 1, RetryDelay: time.Millisecond, CBThreshold: 10, CBTimeout: time.Minute,
})
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
_, _ = client.Do(req)
if gotHeader != "" {
t.Errorf("expected no X-Request-ID header, got %q", gotHeader)
}
}
func TestDoJSON_Success(t *testing.T) {
type payload struct{ Name string }
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(payload{Name: "alice"})
}))
defer srv.Close()
client := New(newLogger(), Config{
Name: "test", Timeout: 5 * time.Second, DialTimeout: 2 * time.Second,
MaxRetries: 1, RetryDelay: time.Millisecond, CBThreshold: 10, CBTimeout: time.Minute,
})
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
result, err := DoJSON[payload](t.Context(), client, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Name != "alice" {
t.Errorf("want alice, got %s", result.Name)
}
}
func TestDoJSON_4xx(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
client := New(newLogger(), Config{
Name: "test", Timeout: 5 * time.Second, DialTimeout: 2 * time.Second,
MaxRetries: 1, RetryDelay: time.Millisecond, CBThreshold: 10, CBTimeout: time.Minute,
})
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
_, err := DoJSON[struct{}](t.Context(), client, req)
if err == nil {
t.Fatal("expected error")
}
var xe *xerrors.Err
if !errors.As(err, &xe) || xe.Code() != xerrors.ErrNotFound {
t.Errorf("want ErrNotFound, got %v", err)
}
}
func TestMapStatusToError_AllCodes(t *testing.T) {
cases := []struct {
status int
code xerrors.Code
}{
{http.StatusNotFound, xerrors.ErrNotFound},
{http.StatusBadRequest, xerrors.ErrInvalidInput},
{http.StatusUnauthorized, xerrors.ErrUnauthorized},
{http.StatusForbidden, xerrors.ErrPermissionDenied},
{http.StatusConflict, xerrors.ErrAlreadyExists},
{http.StatusTooManyRequests, xerrors.ErrUnavailable},
{http.StatusInternalServerError, xerrors.ErrInternal},
}
for _, tc := range cases {
err := MapStatusToError(tc.status, "msg")
var xe *xerrors.Err
if !errors.As(err, &xe) || xe.Code() != tc.code {
t.Errorf("status %d: want %s, got %v", tc.status, tc.code, err)
}
}
}