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:
26
.devcontainer/devcontainer.json
Normal file
26
.devcontainer/devcontainer.json
Normal 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
38
.gitignore
vendored
Normal 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
32
CHANGELOG.md
Normal 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
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.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
57
README.md
Normal 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
8
compliance_test.go
Normal 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
10
doc.go
Normal 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
|
||||
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.
|
||||
10
go.mod
Normal file
10
go.mod
Normal 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
25
go.sum
Normal 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
57
helpers.go
Normal 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
141
httpclient.go
Normal 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
176
httpclient_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user