commit 6026ab8a5e1659404bed705b5760496661718ac3 Author: Rene Nochebuena Date: Thu Mar 19 13:04:37 2026 +0000 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/ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..54f5aae --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..221da82 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bec5133 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..be92ba4 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b33b48 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0ca0d1 --- /dev/null +++ b/README.md @@ -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 | diff --git a/compliance_test.go b/compliance_test.go new file mode 100644 index 0000000..e150a5b --- /dev/null +++ b/compliance_test.go @@ -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{})) diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..572d9c0 --- /dev/null +++ b/doc.go @@ -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 diff --git a/docs/adr/ADR-001-circuit-breaker-and-retry.md b/docs/adr/ADR-001-circuit-breaker-and-retry.md new file mode 100644 index 0000000..517c51e --- /dev/null +++ b/docs/adr/ADR-001-circuit-breaker-and-retry.md @@ -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. diff --git a/docs/adr/ADR-002-request-id-propagation.md b/docs/adr/ADR-002-request-id-propagation.md new file mode 100644 index 0000000..4860deb --- /dev/null +++ b/docs/adr/ADR-002-request-id-propagation.md @@ -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. diff --git a/docs/adr/ADR-003-generic-dojson-helper.md b/docs/adr/ADR-003-generic-dojson-helper.md new file mode 100644 index 0000000..8178446 --- /dev/null +++ b/docs/adr/ADR-003-generic-dojson-helper.md @@ -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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bb25c1e --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e6a3bd9 --- /dev/null +++ b/go.sum @@ -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= diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..7449f8b --- /dev/null +++ b/helpers.go @@ -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) + } +} diff --git a/httpclient.go b/httpclient.go new file mode 100644 index 0000000..5690913 --- /dev/null +++ b/httpclient.go @@ -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 +} diff --git a/httpclient_test.go b/httpclient_test.go new file mode 100644 index 0000000..59ecd83 --- /dev/null +++ b/httpclient_test.go @@ -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) + } + } +}