diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..6fab8b2 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,70 @@ +name: Docs + +on: + push: + branches: [main] + paths: + - 'web/**' + - '.github/workflows/docs.yml' + pull_request: + paths: + - 'web/**' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + +# Never let two deploys race for the same Pages site. Queue instead of +# cancelling: a cancelled deploy can leave the site on a half-published state. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: web/requirements.txt + + - name: Install documentation toolchain + run: pip install -r web/requirements.txt + + # --strict turns broken links and unresolved references into failures, so + # a PR cannot merge documentation that builds with warnings. + - name: Build site + run: mkdocs build --strict -f web/mkdocs.yml + + - name: Upload Pages artifact + if: github.event_name != 'pull_request' + uses: actions/upload-pages-artifact@v3 + with: + path: web/site + + deploy: + name: Deploy + if: github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-latest + + permissions: + pages: write + id-token: write + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index c791869..9cdadcf 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ Thumbs.db # Debug __debug_bin* + +# Documentation site build output +/web/site/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9796ea4..3ae804f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +Four reports from `docs/issues` closed. Every change is additive and backward +compatible: no existing behavior changes unless a new field or hook is set. + +### Added + +- `ErrKindCircuitOpen` and `ErrKindRateLimited` error kinds. `Classify` used to report the package's own sentinels as `ErrKindUnknown`, so a metrics recorder wired above the circuit breaker — the arrangement the documentation suggests — labeled the breaker engaging as an unidentified failure, which is the opposite of what had happened. **No retry verdict changes:** both kinds are non-retryable, exactly as `ErrKindUnknown` was, and retrying either would defeat the protection that produced it. The kinds are appended last in the `ErrorKind` block, so the numeric values shipped in 0.1.0 and 0.2.0 are unchanged. +- `RetryConfig.AttemptTimeout` bounds each individual attempt. A deadline on the caller's context bounds the operation as a whole, so against a dependency that became slow rather than one that fails fast, the first attempt consumed the entire deadline and `MaxAttempts: 3` produced exactly one request on the wire — with every log and metric reporting a plain timeout. The only cure was composing `Timeout` beneath `Retry`, a dependency no type expressed and which silently reverted if the middleware order changed. Zero, the default, keeps the previous semantics. +- `CircuitBreakerConfig.OnStateChange` reports every state transition. At the default `SuccessThreshold` of 1 the half-open phase begins and ends inside a single `RoundTrip`, so no polling frequency can sample it: the transition that shows whether a dependency recovered on its own was unobservable by construction. The callback runs with the breaker's mutex released, on the goroutine of the request that caused the transition, so reading `State()` from inside it is safe. +- `CircuitBreakerWithState` returns the middleware together with the breaker it built. The plain `CircuitBreaker` form discards it, leaving the state of a circuit configured that way unreachable. +- `OnInvalidConfig`, a package-level hook called when a constructor receives configuration it cannot apply and falls back to a pass-through. `Timeout(0)`, `RateLimit{Limiter: nil}` and `NewTokenBucket(0, …)` used to lose a requested protection in complete silence — a client that looks identical to a correctly configured one until the day the protection was needed. Nil by default, which keeps the previous silence. `Metrics{Recorder: nil}` and `Logging{Logger: nil}` stay silent by design: the zero value there means "observability not configured", which is a legitimate default. +- `NewTokenBucketE`, `NewTokenBucket` with the invalid cases returned as an error wrapping the new `ErrInvalidRateLimit` sentinel, instead of degraded to a bucket that does not limit. + +### Changed + +- Doc comments for `Timeout`, `RateLimit` and `NewTokenBucket` now describe the no-op as a fallback rather than a project convention, and point at `OnInvalidConfig`. The previous wording read as a design principle, which is the part that surprised. +- `RetryConfig.MaxAttempts` documents that a context deadline bounds the operation, not each attempt, and points at `AttemptTimeout`. + +### Performance + +- `Classify` is unchanged on the common path: the two sentinels are matched by identity before the transport branches, and by `errors.Is` after them for the wrapped case. Classifying a transport failure stays at ~7.4 ns and zero allocations (measured against ~7.3 ns before the change); an unwrapped sentinel costs ~2.2 ns. Using only `errors.Is` measured 22 ns on the common path when placed first, and 535 ns with 8 allocations on the sentinel path when placed last, so both positions are used deliberately. `BenchmarkClassify_Sentinel` guards the identity check. +- The full middleware stack is unchanged at 12 allocations; the retry and circuit-breaker paths add no allocation when `AttemptTimeout` is zero and `OnStateChange` is nil. + ## [0.2.0] - 2026-08-05 ### Added diff --git a/README.md b/README.md index 0d59541..c19db9c 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,35 @@ State machine: `Closed → Open → Half-Open → Closed/Open` Returns `rhttp.ErrCircuitOpen` when circuit is open. +**Observing transitions.** Use `OnStateChange`, not a poll: + +```go +rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 5, + ResetTimeout: 30 * time.Second, + OnStateChange: func(from, to rhttp.CircuitState) { + log.Printf("circuit %s -> %s", from, to) + }, +}) +``` + +At the default `SuccessThreshold` of 1, the half-open phase is entered and left +inside a single `RoundTrip`, so **no polling frequency can sample it** — a poller +sees `closed → open → closed` and the recovery mechanism is invisible. The +callback reports rather than samples, so a transition lasting nanoseconds still +shows up. It runs with the breaker's mutex released, on the goroutine of the +request that caused the transition, so reading `State()` from inside it is safe; +it must not block, because the request cannot proceed until it returns. + +When you need a handle to the breaker itself, use `CircuitBreakerWithState` (or +`NewCircuitBreaker(...).Middleware()` to share one circuit across clients): + +```go +mw, breaker := rhttp.CircuitBreakerWithState(cfg) +client := rhttp.New(rhttp.WithMiddleware(mw)) +_ = breaker.State() +``` + ### Rate Limiting ```go @@ -209,6 +238,37 @@ client := rhttp.New( ) ``` +A non-positive rate or a burst below 1 cannot produce a limiter, so +`NewTokenBucket` falls back to not limiting. When the values come from +configuration, use `NewTokenBucketE` and fail at startup instead: + +```go +limiter, err := rhttp.NewTokenBucketE(cfg.Rate, cfg.Burst) +if err != nil { + return err // errors.Is(err, rhttp.ErrInvalidRateLimit) +} +``` + +### Invalid configuration + +`Timeout(0)`, `RateLimit{Limiter: nil}` and `NewTokenBucket(0, …)` fall back to a +pass-through rather than busy-looping or blocking forever. The fallback is safe, +but a protection that is silently absent gets discovered during the incident it +was meant to prevent — so set `OnInvalidConfig` at startup and find out at deploy +time instead: + +```go +func init() { + rhttp.OnInvalidConfig = func(component, reason string) { + log.Printf("rhttp: %s is inert: %s", component, reason) + } +} +``` + +Nil by default. `Metrics{Recorder: nil}` and `Logging{Logger: nil}` stay silent by +design: there the zero value means "observability not configured", which is a +legitimate default and loses no protection. + ### Logging ```go @@ -280,6 +340,10 @@ if err != nil { // NXDOMAIN: the name does not exist. Permanent, never retried case rhttp.ErrKindTLS: // Certificate error + case rhttp.ErrKindCircuitOpen: + // The client's own breaker refused the call: never reached the network + case rhttp.ErrKindRateLimited: + // The client's own quota refused the call: never reached the network } // Or use helpers @@ -289,6 +353,13 @@ if err != nil { } ``` +`ErrKindCircuitOpen` and `ErrKindRateLimited` name the two outcomes the client +produces itself. They matter most where classification is wired into metrics: a +breaker engaging is the most informative signal the stack emits — the moment the +protection kicked in — and it must not share a bucket with "a failure this +library could not identify". Neither is retryable: retrying inside the same +operation would defeat the protection that produced the error. + `ErrKindDNS` and `ErrKindDNSNotFound` are split because they call for opposite handling: a SERVFAIL may clear on the next lookup, while an NXDOMAIN cannot — retrying it only spends the attempt budget and the full backoff schedule on an @@ -325,6 +396,24 @@ Where you put `Timeout` relative to `Retry` selects one of two semantics — bot See the runnable `ExampleRetry_totalBudget` and `ExampleRetry_perAttemptTimeout` for both wirings. +`RetryConfig.AttemptTimeout` expresses the per-attempt semantics without +depending on the order: + +```go +rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + AttemptTimeout: 2 * time.Second, // each attempt, wherever Timeout sits +}) +``` + +This matters because **a deadline on the caller's context bounds the operation, +not each attempt**. Against a dependency that has become slow rather than one +that fails fast, the first attempt can consume the whole deadline and no retry +happens at all — `MaxAttempts: 3` yields one request on the wire, and the error, +the log and the metric all report a plain timeout. Set `AttemptTimeout`, or place +`Timeout` immediately beneath `Retry` and verify it with a counting middleware +below both; there is no other way to tell the two configurations apart. + ### Retry vs CircuitBreaker | Order | Effect | @@ -473,7 +562,12 @@ All PRs must pass CI checks before merging. ## Roadmap -> **Status:** v0.2.0 released (Phase 1 complete). Phase 2 is the next focus. +> **Status:** Phase 1 is complete and v0.2.0 is the latest tag. Part of the +> Phase 1 hardening documented above is not in that tag yet: +> `RetryConfig.AttemptTimeout`, `CircuitBreakerConfig.OnStateChange`, +> `CircuitBreakerWithState`, `OnInvalidConfig`, `NewTokenBucketE` and the +> `ErrKindCircuitOpen` / `ErrKindRateLimited` kinds ship with the next release — +> see `[Unreleased]` in [CHANGELOG.md](CHANGELOG.md). Phase 2 is the next focus. ### Phase 1: Foundation (Completed) @@ -489,7 +583,7 @@ All PRs must pass CI checks before merging. - [x] **Rate limiting** - Token bucket behind the pluggable RateLimiter interface - [x] **Logging middleware** - Pluggable `Logger` interface - [x] **Metrics middleware** - Pluggable `MetricsRecorder` interface -- [x] **Error classification** - Timeout, connection, DNS, TLS, temporary +- [x] **Error classification** - Timeout, cancellation, connection, DNS (transient and NXDOMAIN), TLS - [x] **Fluent API** - Resty-style `RequestBuilder` plus the `DecodeJSON` helper - [x] **Zero dependencies** - Only Go standard library @@ -512,7 +606,7 @@ All PRs must pass CI checks before merging. ### Phase 4: Developer Experience -- [ ] **Auto marshaling** - JSON, XML, Protocol Buffers, MessagePack +- [ ] **Auto marshaling** - JSON, XML and form bodies already ship (`SetBodyJSON`, `SetBodyXML`, `SetBodyForm`, `DecodeJSON`); Protocol Buffers and MessagePack pending - [ ] **OAuth2 support** - Automatic token refresh - [ ] **Debug mode** - Request/response dump, curl generation - [ ] **Response validation** - JSON Schema, status assertions diff --git a/benchmark_test.go b/benchmark_test.go index c67bb68..57c719e 100644 --- a/benchmark_test.go +++ b/benchmark_test.go @@ -202,3 +202,18 @@ func BenchmarkClassify_Error(b *testing.B) { _ = rhttp.Classify(err) } } + +// Guards the identity check at the top of classifyError. Without it an +// unwrapped sentinel falls through every transport branch to the errors.Is at +// the bottom, which measured 535 ns and 8 allocs — the escaping errors.As +// targets — against the ~2 ns here. +func BenchmarkClassify_Sentinel(b *testing.B) { + err := rhttp.ErrCircuitOpen + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _ = rhttp.Classify(err) + } +} diff --git a/circuitbreaker.go b/circuitbreaker.go index 18f19d9..2d7ef21 100644 --- a/circuitbreaker.go +++ b/circuitbreaker.go @@ -48,6 +48,20 @@ type CircuitBreakerConfig struct { // SuccessThreshold is the number of consecutive successful probes required // in Half-Open state to close the circuit. If <= 0, defaults to 1. SuccessThreshold int + + // OnStateChange is called after every transition. Use it to publish circuit + // state to logs or metrics: polling State() cannot observe a transition that + // completes within a single request, which is what the Half-Open phase does + // at the default SuccessThreshold of 1. + // + // It runs on the goroutine of the request that caused the transition, with + // the breaker's mutex released, so reading State() from inside is safe. It + // must not block: the request cannot proceed until it returns. + // + // Transitions are published in order for a single request stream. Under + // concurrency two transitions may be published in an order different from + // the one in which they occurred, because the mutex is released first. + OnStateChange func(from, to CircuitState) } func DefaultIsFailure(resp *http.Response, err error) bool { @@ -78,6 +92,10 @@ func newCircuitBreaker(cfg CircuitBreakerConfig) *circuitBreaker { return &circuitBreaker{cfg: cfg, state: CircuitClosed} } +// CircuitBreaker returns a middleware backed by its own breaker, created on each +// application of the middleware. The breaker itself is not returned: set +// cfg.OnStateChange to observe its transitions, or use CircuitBreakerWithState +// to get a handle to it. func CircuitBreaker(cfg CircuitBreakerConfig) Middleware { return func(next http.RoundTripper) http.RoundTripper { return circuitBreakerRoundTripper{next: next, cb: newCircuitBreaker(cfg)} @@ -96,82 +114,119 @@ type circuitBreaker struct { halfOpenSuccess int } -// allowRequest reports whether the request is admitted and returns the -// generation under which it was admitted. Every state transition bumps the +// stateTransition is a state change captured under the breaker's mutex, to be +// published once it is released. +type stateTransition struct { + from, to CircuitState +} + +// setState moves the breaker to a new state and returns the transition for +// publication, or nil when nobody is listening. Every transition bumps the // generation, so recordResult can discard results from requests that outlived -// the state in which they were admitted. -func (cb *circuitBreaker) allowRequest() (admitted bool, gen uint64) { +// the state in which they were admitted. The caller holds cb.mu. +func (cb *circuitBreaker) setState(to CircuitState) *stateTransition { + from := cb.state + cb.state = to + cb.generation++ + + if cb.cfg.OnStateChange == nil { + return nil + } + return &stateTransition{from: from, to: to} +} + +// publish invokes OnStateChange. It must run with cb.mu released: a callback +// that logs, records a metric or reads State() would otherwise deadlock. +func (cb *circuitBreaker) publish(t *stateTransition) { + if t == nil { + return + } + cb.cfg.OnStateChange(t.from, t.to) +} + +// tryAdmit is allowRequest's locked section: it reports whether the request is +// admitted, the generation under which it was admitted, and the transition it +// caused, if any. +func (cb *circuitBreaker) tryAdmit() (admitted bool, gen uint64, t *stateTransition) { cb.mu.Lock() defer cb.mu.Unlock() switch cb.state { case CircuitClosed: - return true, cb.generation + return true, cb.generation, nil case CircuitOpen: if time.Since(cb.lastFailureTime) >= cb.cfg.ResetTimeout { - cb.state = CircuitHalfOpen - cb.generation++ + t = cb.setState(CircuitHalfOpen) cb.halfOpenSuccess = 0 cb.halfOpenInFlight = 1 - return true, cb.generation + return true, cb.generation, t } - return false, cb.generation + return false, cb.generation, nil case CircuitHalfOpen: if cb.halfOpenInFlight < cb.cfg.MaxHalfOpenRequests { cb.halfOpenInFlight++ - return true, cb.generation + return true, cb.generation, nil } - return false, cb.generation + return false, cb.generation, nil default: - return true, cb.generation + return true, cb.generation, nil } } -func (cb *circuitBreaker) recordClosedResult(isFailure bool) { +// allowRequest reports whether the request is admitted and returns the +// generation under which it was admitted. +func (cb *circuitBreaker) allowRequest() (admitted bool, gen uint64) { + admitted, gen, transition := cb.tryAdmit() + cb.publish(transition) + return admitted, gen +} + +func (cb *circuitBreaker) recordClosedResult(isFailure bool) *stateTransition { if !isFailure { cb.failures = 0 - return + return nil } cb.failures++ cb.lastFailureTime = time.Now() if cb.failures >= cb.cfg.FailureThreshold { - cb.state = CircuitOpen - cb.generation++ + return cb.setState(CircuitOpen) } + return nil } -func (cb *circuitBreaker) recordHalfOpenResult(isFailure bool) { +func (cb *circuitBreaker) recordHalfOpenResult(isFailure bool) *stateTransition { if cb.halfOpenInFlight > 0 { cb.halfOpenInFlight-- } if isFailure { - cb.state = CircuitOpen - cb.generation++ + transition := cb.setState(CircuitOpen) cb.lastFailureTime = time.Now() cb.failures = cb.cfg.FailureThreshold cb.halfOpenSuccess = 0 cb.halfOpenInFlight = 0 - return + return transition } cb.halfOpenSuccess++ if cb.halfOpenSuccess < cb.cfg.SuccessThreshold { - return + return nil } - cb.state = CircuitClosed - cb.generation++ + transition := cb.setState(CircuitClosed) cb.failures = 0 cb.halfOpenSuccess = 0 cb.halfOpenInFlight = 0 + return transition } -func (cb *circuitBreaker) recordResult(resp *http.Response, err error, gen uint64) { +// applyResult is recordResult's locked section, returning the transition the +// result caused, if any. +func (cb *circuitBreaker) applyResult(resp *http.Response, err error, gen uint64) *stateTransition { isFailure := cb.cfg.IsFailure(resp, err) cb.mu.Lock() @@ -181,15 +236,15 @@ func (cb *circuitBreaker) recordResult(resp *http.Response, err error, gen uint6 // was admitted no longer exists, so counting it would corrupt the current // one (e.g. a slow Closed request closing a Half-Open circuit). if gen != cb.generation { - return + return nil } switch cb.state { case CircuitClosed: - cb.recordClosedResult(isFailure) + return cb.recordClosedResult(isFailure) case CircuitHalfOpen: - cb.recordHalfOpenResult(isFailure) + return cb.recordHalfOpenResult(isFailure) case CircuitOpen: // Unreachable: a request is only admitted while Closed or on the @@ -197,9 +252,17 @@ func (cb *circuitBreaker) recordResult(resp *http.Response, err error, gen uint6 // A result observed while the breaker sits in Open therefore carries a // stale generation and was already discarded above. Kept for switch // exhaustiveness. + return nil + + default: + return nil } } +func (cb *circuitBreaker) recordResult(resp *http.Response, err error, gen uint64) { + cb.publish(cb.applyResult(resp, err, gen)) +} + // State returns the current state of the circuit breaker. // Useful for monitoring and testing. func (cb *circuitBreaker) State() CircuitState { @@ -254,3 +317,17 @@ func (s *SharedCircuitBreaker) Middleware() Middleware { func (s *SharedCircuitBreaker) State() CircuitState { return s.cb.State() } + +// CircuitBreakerWithState behaves like CircuitBreaker and additionally returns +// the breaker it built, for callers that need to reach the state machine they +// just configured. +// +// To publish transitions, prefer CircuitBreakerConfig.OnStateChange: polling the +// returned State() cannot observe a transition that completes within a single +// request, which is what the Half-Open phase does at the default +// SuccessThreshold of 1. +func CircuitBreakerWithState(cfg CircuitBreakerConfig) (Middleware, *SharedCircuitBreaker) { + shared := NewCircuitBreaker(cfg) + mw := shared.Middleware() + return mw, shared +} diff --git a/circuitbreaker_test.go b/circuitbreaker_test.go index a044543..fc4c068 100644 --- a/circuitbreaker_test.go +++ b/circuitbreaker_test.go @@ -900,3 +900,128 @@ func TestSharedCircuitBreaker_StateObservesTransitions(t *testing.T) { t.Fatalf("expected closed after a successful probe, got %v", got) } } + +type transitionRecorder struct { + mu sync.Mutex + seen []string +} + +func (r *transitionRecorder) record(from, to rhttp.CircuitState) { + r.mu.Lock() + defer r.mu.Unlock() + r.seen = append(r.seen, from.String()+"->"+to.String()) +} + +func (r *transitionRecorder) snapshot() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.seen...) +} + +func TestCircuitBreaker_OnStateChangeReportsHalfOpen(t *testing.T) { + var broken atomic.Bool + broken.Store(true) + + rt := rhttp.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + if broken.Load() { + return nil, errors.New("connection refused") + } + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + rec := &transitionRecorder{} + breaker := rhttp.NewCircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 2, + ResetTimeout: 10 * time.Millisecond, + OnStateChange: rec.record, + }) + + c := rhttp.New(rhttp.WithTransport(rt), rhttp.WithMiddleware(breaker.Middleware())) + + openCircuit(t, c, 2) + broken.Store(false) + time.Sleep(30 * time.Millisecond) + + // One probe: open -> half-open -> closed, all within this round trip. + openCircuit(t, c, 1) + + if state := breaker.State(); state != rhttp.CircuitClosed { + t.Fatalf("expected the circuit to be closed, got %v", state) + } + + got := rec.snapshot() + want := []string{"closed->open", "open->half-open", "half-open->closed"} + + if len(got) != len(want) { + t.Fatalf("transitions = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("transition %d = %q, want %q (full: %v)", i, got[i], want[i], got) + } + } +} + +func TestCircuitBreaker_OnStateChangeRunsWithLockReleased(t *testing.T) { + rt := rhttp.RoundTripperFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("connection refused") + }) + + var breaker *rhttp.SharedCircuitBreaker + observed := make(chan rhttp.CircuitState, 1) + + breaker = rhttp.NewCircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 1, + ResetTimeout: time.Hour, + OnStateChange: func(_, _ rhttp.CircuitState) { + observed <- breaker.State() + }, + }) + + c := rhttp.New(rhttp.WithTransport(rt), rhttp.WithMiddleware(breaker.Middleware())) + + done := make(chan struct{}) + go func() { + defer close(done) + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("the request never returned: OnStateChange is holding the breaker's mutex") + } + + select { + case state := <-observed: + if state != rhttp.CircuitOpen { + t.Errorf("State() inside the callback = %v, want %v", state, rhttp.CircuitOpen) + } + default: + t.Error("OnStateChange was never called for closed->open") + } +} + +func TestCircuitBreakerWithState_ReturnsTheBreaker(t *testing.T) { + rt := rhttp.RoundTripperFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("connection refused") + }) + + mw, breaker := rhttp.CircuitBreakerWithState(rhttp.CircuitBreakerConfig{ + FailureThreshold: 1, + ResetTimeout: time.Hour, + }) + + if state := breaker.State(); state != rhttp.CircuitClosed { + t.Fatalf("a fresh breaker should start closed, got %v", state) + } + + c := rhttp.New(rhttp.WithTransport(rt), rhttp.WithMiddleware(mw)) + openCircuit(t, c, 1) + + if state := breaker.State(); state != rhttp.CircuitOpen { + t.Errorf("state = %v, want %v: the returned handle must observe the middleware "+ + "it was created with", state, rhttp.CircuitOpen) + } +} diff --git a/diagnostics.go b/diagnostics.go new file mode 100644 index 0000000..fc3ab3a --- /dev/null +++ b/diagnostics.go @@ -0,0 +1,28 @@ +package rhttp + +// OnInvalidConfig is called when a constructor receives configuration it cannot +// apply and falls back to a pass-through. It reports the component and the +// reason, so a protection that is absent becomes visible at startup rather than +// during the incident it was meant to prevent. +// +// Nil by default, which keeps the fallback silent and the behavior of earlier +// versions unchanged. Assign it once during startup, before constructing any +// client or limiter: it is a plain package variable, and mutating it while +// another goroutine builds middleware is a data race. +// +// rhttp.OnInvalidConfig = func(component, reason string) { +// log.Printf("rhttp: %s is inert: %s", component, reason) +// } +// +// Only components whose absence loses a protection report here. A nil +// MetricsConfig.Recorder or LoggingConfig.Logger means "observability not +// configured", which is a legitimate default and stays silent. +var OnInvalidConfig func(component string, reason string) + +// reportInvalidConfig notifies OnInvalidConfig when it is set. +func reportInvalidConfig(component, reason string) { + if OnInvalidConfig == nil { + return + } + OnInvalidConfig(component, reason) +} diff --git a/diagnostics_test.go b/diagnostics_test.go new file mode 100644 index 0000000..5bb615b --- /dev/null +++ b/diagnostics_test.go @@ -0,0 +1,70 @@ +package rhttp_test + +import ( + "net/http" + "testing" + "time" + + "github.com/oswaldom-code/rhttp" +) + +func TestOnInvalidConfig_ReportsLostProtections(t *testing.T) { + var reported []string + + rhttp.OnInvalidConfig = func(component, reason string) { + if reason == "" { + t.Errorf("%s was reported without a reason", component) + } + reported = append(reported, component) + } + t.Cleanup(func() { rhttp.OnInvalidConfig = nil }) + + rhttp.Timeout(0) + rhttp.Timeout(-time.Second) + rhttp.RateLimit(rhttp.RateLimitConfig{}) + rhttp.NewTokenBucket(0, 10) + rhttp.NewTokenBucket(10, 0) + + // A nil Recorder or Logger means "observability not configured", which is a + // legitimate default: nothing is lost and nothing must be reported. + rhttp.Metrics(rhttp.MetricsConfig{}) + rhttp.Logging(rhttp.LoggingConfig{}) + + // A valid configuration is silent too. + rhttp.Timeout(time.Second) + rhttp.NewTokenBucket(10, 1) + + want := []string{"Timeout", "Timeout", "RateLimit", "TokenBucket", "TokenBucket"} + + if len(reported) != len(want) { + t.Fatalf("reported = %v, want %v", reported, want) + } + for i := range want { + if reported[i] != want[i] { + t.Errorf("report %d = %q, want %q (full: %v)", i, reported[i], want[i], reported) + } + } +} + +func TestOnInvalidConfig_NilKeepsTheFallbackSilent(t *testing.T) { + if rhttp.OnInvalidConfig != nil { + t.Fatal("OnInvalidConfig must default to nil") + } + + probe := &probeRoundTripper{} + + if rhttp.Timeout(0)(probe) != http.RoundTripper(probe) { + t.Error("Timeout(0) must still fall back to a pass-through") + } + if !rhttp.NewTokenBucket(0, 10).TryAcquire() { + t.Error("an invalid bucket must still fall back to not limiting") + } +} + +// probeRoundTripper is a comparable innermost transport, so a middleware can be +// checked for being a pass-through: Go func values are not comparable. +type probeRoundTripper struct{} + +func (*probeRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return nil, nil +} diff --git a/errorclass.go b/errorclass.go index 04e5237..753f369 100644 --- a/errorclass.go +++ b/errorclass.go @@ -35,6 +35,14 @@ const ( // ErrKindDNSNotFound indicates the name does not exist (NXDOMAIN). Unlike // ErrKindDNS this is permanent: retrying the same name cannot succeed. ErrKindDNSNotFound + + // ErrKindCircuitOpen indicates the client's own circuit breaker refused the + // call: the request never reached the network. + ErrKindCircuitOpen + + // ErrKindRateLimited indicates the client's own quota refused the call: the + // request never reached the network. + ErrKindRateLimited ) // String returns a human-readable name for the error kind. @@ -52,12 +60,20 @@ func (k ErrorKind) String() string { return "dns_not_found" case ErrKindTLS: return "tls" + case ErrKindCircuitOpen: + return "circuit_open" + case ErrKindRateLimited: + return "rate_limited" default: return "unknown" } } // IsRetryable returns true if the error kind is typically safe to retry. +// +// ErrKindCircuitOpen and ErrKindRateLimited are deliberately absent: both are +// the client protecting itself, and retrying inside the same operation defeats +// the protection that produced them. func (k ErrorKind) IsRetryable() bool { switch k { case ErrKindTimeout, ErrKindConnection, ErrKindDNS: @@ -141,11 +157,42 @@ func classifyConnection(err error) ErrorKind { return ErrKindUnknown } +// classifySentinel matches one of the package's own sentinels by identity: two +// interface comparisons, cheap enough to run before any transport inspection, +// and unwrapped is how these two travel in practice. +func classifySentinel(err error) ErrorKind { + switch err { + case ErrCircuitOpen: + return ErrKindCircuitOpen + case ErrRateLimited: + return ErrKindRateLimited + default: + return ErrKindUnknown + } +} + +// classifyWrappedSentinel matches a sentinel anywhere in the error chain. It +// walks, so it runs only once every transport branch has missed. +func classifyWrappedSentinel(err error) ErrorKind { + if errors.Is(err, ErrCircuitOpen) { + return ErrKindCircuitOpen + } + if errors.Is(err, ErrRateLimited) { + return ErrKindRateLimited + } + + return ErrKindUnknown +} + func classifyError(err error) ErrorKind { if err == nil { return ErrKindUnknown } + if kind := classifySentinel(err); kind != ErrKindUnknown { + return kind + } + if errors.Is(err, context.DeadlineExceeded) { return ErrKindTimeout } @@ -180,7 +227,11 @@ func classifyError(err error) ErrorKind { return kind } - return classifyConnection(err) + if kind := classifyConnection(err); kind != ErrKindUnknown { + return kind + } + + return classifyWrappedSentinel(err) } // IsTimeout returns true if the error is a timeout error. diff --git a/errorclass_test.go b/errorclass_test.go index 29fdc6f..915d43d 100644 --- a/errorclass_test.go +++ b/errorclass_test.go @@ -4,10 +4,13 @@ import ( "context" "crypto/x509" "errors" + "fmt" "net" + "net/http" "net/url" "syscall" "testing" + "time" "github.com/oswaldom-code/rhttp" ) @@ -331,6 +334,9 @@ func TestClassify_AllKindsAreReachable(t *testing.T) { rhttp.ErrKindTLS: x509.UnknownAuthorityError{}, rhttp.ErrKindDNSNotFound: &net.DNSError{Err: "no such host", IsNotFound: true}, + + rhttp.ErrKindCircuitOpen: rhttp.ErrCircuitOpen, + rhttp.ErrKindRateLimited: rhttp.ErrRateLimited, } for kind, err := range producers { @@ -369,3 +375,103 @@ func TestIsRetryable(t *testing.T) { t.Error("expected nil to not be retryable") } } + +func TestClassify_Sentinels(t *testing.T) { + tests := []struct { + name string + err error + kind rhttp.ErrorKind + want string + }{ + {"circuit open", rhttp.ErrCircuitOpen, rhttp.ErrKindCircuitOpen, "circuit_open"}, + {"rate limited", rhttp.ErrRateLimited, rhttp.ErrKindRateLimited, "rate_limited"}, + { + "circuit open wrapped", + fmt.Errorf("calling users service: %w", rhttp.ErrCircuitOpen), + rhttp.ErrKindCircuitOpen, + "circuit_open", + }, + { + "rate limited wrapped", + fmt.Errorf("calling users service: %w", rhttp.ErrRateLimited), + rhttp.ErrKindRateLimited, + "rate_limited", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + classified := rhttp.Classify(tt.err) + + if classified.Kind != tt.kind { + t.Errorf("Kind = %v, want %v", classified.Kind, tt.kind) + } + if got := classified.Kind.String(); got != tt.want { + t.Errorf("Kind.String() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestClassify_SentinelsStayNonRetryable(t *testing.T) { + for _, err := range []error{rhttp.ErrCircuitOpen, rhttp.ErrRateLimited} { + if rhttp.IsRetryable(err) { + t.Errorf("IsRetryable(%v) = true, want false", err) + } + if rhttp.DefaultIsRetryable(nil, err) { + t.Errorf("DefaultIsRetryable(nil, %v) = true, want false", err) + } + } +} + +func TestErrorKind_SentinelKindsAreAppended(t *testing.T) { + if rhttp.ErrKindUnknown != 0 { + t.Errorf("ErrKindUnknown = %d, want 0", rhttp.ErrKindUnknown) + } + if rhttp.ErrKindCircuitOpen <= rhttp.ErrKindDNSNotFound { + t.Error("ErrKindCircuitOpen must come after ErrKindDNSNotFound") + } + if rhttp.ErrKindRateLimited <= rhttp.ErrKindCircuitOpen { + t.Error("ErrKindRateLimited must come after ErrKindCircuitOpen") + } +} + +func TestClassify_MetricsAboveBreakerNameTheOutcome(t *testing.T) { + rt := rhttp.RoundTripperFunc(func(*http.Request) (*http.Response, error) { + return nil, &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED} + }) + + var kinds []string + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware( + rhttp.Metrics(rhttp.MetricsConfig{ + Recorder: rhttp.MetricsRecorderFunc(func(e rhttp.MetricEvent) { + if e.Error != nil { + kinds = append(kinds, rhttp.Classify(e.Error).Kind.String()) + } + }), + }), + rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 2, + ResetTimeout: time.Hour, + }), + ), + ) + + for i := 0; i < 6; i++ { + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + } + + want := []string{"connection", "connection", "circuit_open", "circuit_open", "circuit_open", "circuit_open"} + + if len(kinds) != len(want) { + t.Fatalf("kinds = %v, want %v", kinds, want) + } + for i := range want { + if kinds[i] != want[i] { + t.Errorf("kind %d = %q, want %q (full: %v)", i, kinds[i], want[i], kinds) + } + } +} diff --git a/errors.go b/errors.go index 45d9828..2f27edb 100644 --- a/errors.go +++ b/errors.go @@ -11,4 +11,8 @@ var ( // ErrRateLimited is returned when the rate limit is exceeded and WaitOnLimit is false. ErrRateLimited = errors.New("rhttp: rate limit exceeded") + + // ErrInvalidRateLimit is returned by NewTokenBucketE for a rate or burst that + // cannot produce a limiter. + ErrInvalidRateLimit = errors.New("rhttp: invalid rate limit") ) diff --git a/options.go b/options.go index 96f46f2..95f7e1a 100644 --- a/options.go +++ b/options.go @@ -17,6 +17,10 @@ func defaultConfig() *config { } // WithTransport sets a custom http.RoundTripper. +// +// A nil transport is discarded and DefaultTransport is kept, so no protection is +// lost. Unlike the middleware constructors, this case is not reported through +// OnInvalidConfig. func WithTransport(rt http.RoundTripper) Option { return func(c *config) { if rt != nil { diff --git a/ratelimit.go b/ratelimit.go index 482c061..4bbc4d7 100644 --- a/ratelimit.go +++ b/ratelimit.go @@ -2,6 +2,7 @@ package rhttp import ( "context" + "fmt" "net/http" "strconv" "sync" @@ -34,12 +35,15 @@ type TokenBucket struct { // rate: requests per second allowed // burst: maximum burst size (bucket capacity) // -// A non-positive rate or a burst below 1 is invalid configuration: the returned -// bucket does not limit (it allows every request), following the project -// convention that invalid config becomes a no-op rather than a busy-loop or a -// permanent block. +// A non-positive rate or a burst below 1 cannot produce a limiter. Rather than +// busy-loop or block permanently, the returned bucket falls back to not +// limiting: it allows every request. That fallback is reported through +// OnInvalidConfig, and NewTokenBucketE returns it as an error instead. func NewTokenBucket(rate float64, burst int) *TokenBucket { if rate <= 0 || burst < 1 { + reportInvalidConfig("TokenBucket", fmt.Sprintf( + "rate=%v burst=%d: rate must be positive and burst at least 1; the bucket does not limit", + rate, burst)) return &TokenBucket{unlimited: true} } return &TokenBucket{ @@ -51,6 +55,20 @@ func NewTokenBucket(rate float64, burst int) *TokenBucket { } } +// NewTokenBucketE is NewTokenBucket with the invalid cases reported instead of +// silently disabled. Prefer it whenever the rate comes from configuration that +// could be wrong: a bucket that does not limit is indistinguishable from a +// correctly configured one until the load it was meant to shape arrives. +// +// The returned error wraps ErrInvalidRateLimit. +func NewTokenBucketE(rate float64, burst int) (*TokenBucket, error) { + if rate <= 0 || burst < 1 { + return nil, fmt.Errorf("%w: rate=%v burst=%d: rate must be positive and burst at least 1", + ErrInvalidRateLimit, rate, burst) + } + return NewTokenBucket(rate, burst), nil +} + // WaitContext blocks until a token is available or context is canceled. func (tb *TokenBucket) WaitContext(ctx context.Context) error { for { @@ -119,8 +137,12 @@ type RateLimitConfig struct { } // RateLimit returns a middleware that applies rate limiting to requests. +// +// A nil Limiter cannot rate-limit anything, so the middleware falls back to a +// pass-through. That fallback is reported through OnInvalidConfig. func RateLimit(cfg RateLimitConfig) Middleware { if cfg.Limiter == nil { + reportInvalidConfig("RateLimit", "Limiter is nil: requests are not rate-limited") return func(next http.RoundTripper) http.RoundTripper { return next } diff --git a/ratelimit_test.go b/ratelimit_test.go index ea6fe54..b0e9836 100644 --- a/ratelimit_test.go +++ b/ratelimit_test.go @@ -487,3 +487,46 @@ func TestRateLimit_RespectRetryAfterConcurrent(t *testing.T) { } wg.Wait() } + +// NewTokenBucket falls back to not limiting on invalid configuration, which is +// indistinguishable from a correctly configured bucket until the load it was +// meant to shape arrives. NewTokenBucketE reports it instead. +func TestNewTokenBucketE_InvalidConfigIsAnError(t *testing.T) { + tests := []struct { + name string + rate float64 + burst int + }{ + {"zero rate", 0, 10}, + {"negative rate", -1, 10}, + {"zero burst", 10, 0}, + {"negative burst", 10, -1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bucket, err := rhttp.NewTokenBucketE(tt.rate, tt.burst) + + if !errors.Is(err, rhttp.ErrInvalidRateLimit) { + t.Errorf("error = %v, want it to wrap ErrInvalidRateLimit", err) + } + if bucket != nil { + t.Error("an invalid configuration must not yield a usable bucket") + } + }) + } +} + +func TestNewTokenBucketE_ValidConfigLimits(t *testing.T) { + bucket, err := rhttp.NewTokenBucketE(10, 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !bucket.TryAcquire() { + t.Error("a valid bucket should admit its first request") + } + if bucket.TryAcquire() { + t.Error("a burst of 1 should admit only one request") + } +} diff --git a/retry.go b/retry.go index 0f6c05e..22263f6 100644 --- a/retry.go +++ b/retry.go @@ -1,6 +1,7 @@ package rhttp import ( + "context" "io" "net/http" "time" @@ -11,6 +12,14 @@ type RetryConfig struct { // MaxAttempts is the maximum number of attempts (including the first one). MaxAttempts int + // AttemptTimeout bounds each individual attempt. Zero means the attempt is + // bounded only by the caller's context, in which case the first attempt may + // consume the whole budget and no retry will be made. + // + // The per-attempt context is derived from the caller's, so it can only + // shorten the operation, never extend it. + AttemptTimeout time.Duration + // Backoff returns the duration to wait before the nth retry (0-indexed). // It receives the response of the attempt that triggered the retry (nil if // it produced no response). If nil, exponential backoff is used. @@ -52,7 +61,7 @@ type retryRoundTripper struct { func (r retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { if !r.canRetry(req) { - return r.next.RoundTrip(req) + return r.attempt(req) } var resp *http.Response @@ -72,7 +81,7 @@ func (r retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) return nil, prepErr } - resp, err = r.next.RoundTrip(attemptReq) + resp, err = r.attempt(attemptReq) if !r.cfg.IsRetryable(resp, err) { return resp, err @@ -114,6 +123,28 @@ func (r retryRoundTripper) prepareRequest(req *http.Request, attempt int) (*http return attemptReq, nil } +func (r retryRoundTripper) attempt(req *http.Request) (*http.Response, error) { + if r.cfg.AttemptTimeout <= 0 { + return r.next.RoundTrip(req) + } + + ctx, cancel := context.WithTimeout(req.Context(), r.cfg.AttemptTimeout) + + resp, err := r.next.RoundTrip(req.WithContext(ctx)) + if err != nil { + cancel() + return resp, err + } + + if resp.Body == nil { + cancel() + return resp, nil + } + + resp.Body = &cancelBody{ReadCloser: resp.Body, cancel: cancel} + return resp, nil +} + func (r retryRoundTripper) waitBackoff(req *http.Request, attempt int, prev *http.Response) error { timer := time.NewTimer(r.cfg.Backoff(attempt-1, prev)) defer timer.Stop() diff --git a/retry_test.go b/retry_test.go index 5407d62..c31145b 100644 --- a/retry_test.go +++ b/retry_test.go @@ -539,3 +539,158 @@ func TestRetry_BackoffCanceledClosesRequestBody(t *testing.T) { t.Error("request body was not closed when backoff was canceled") } } + +// unresponsiveTransport answers no request before its context is done, and +// counts the attempts that reach it. +func unresponsiveTransport(attempts *atomic.Int64) rhttp.RoundTripperFunc { + return func(req *http.Request) (*http.Response, error) { + attempts.Add(1) + select { + case <-req.Context().Done(): + return nil, req.Context().Err() + case <-time.After(5 * time.Second): + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + } + } +} + +// contextAwareBody fails once its request context is done, the way a real +// transport body does. +type contextAwareBody struct { + ctx context.Context + data string + read bool +} + +func (b *contextAwareBody) Read(p []byte) (int, error) { + if err := b.ctx.Err(); err != nil { + return 0, err + } + if b.read { + return 0, io.EOF + } + b.read = true + return copy(p, b.data), nil +} + +func (b *contextAwareBody) Close() error { return nil } + +// Regression: a deadline on the caller's context bounds the operation, not each +// attempt, so against a slow dependency the first attempt consumed the whole +// budget and MaxAttempts was silently reduced to 1. AttemptTimeout gives each +// attempt its own budget without depending on middleware order. +func TestRetry_AttemptTimeoutHonoursTheBudget(t *testing.T) { + var attempts atomic.Int64 + + c := rhttp.New( + rhttp.WithTransport(unresponsiveTransport(&attempts)), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + AttemptTimeout: 50 * time.Millisecond, + Backoff: rhttp.ConstantBackoff(10 * time.Millisecond), + })), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + if _, err := c.Do(ctx, req); err == nil { + t.Fatal("expected an error from the unresponsive transport") + } + + if got := attempts.Load(); got != 3 { + t.Errorf("attempts = %d, want 3", got) + } +} + +// A zero AttemptTimeout must leave the previous semantics untouched: the attempt +// is bounded only by the caller's context, so a first attempt that exhausts the +// deadline still consumes the whole budget. +func TestRetry_AttemptTimeoutZeroKeepsContextSemantics(t *testing.T) { + var attempts atomic.Int64 + + c := rhttp.New( + rhttp.WithTransport(unresponsiveTransport(&attempts)), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + Backoff: rhttp.ConstantBackoff(10 * time.Millisecond), + })), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + if _, err := c.Do(ctx, req); err == nil { + t.Fatal("expected an error from the unresponsive transport") + } + + if got := attempts.Load(); got != 1 { + t.Errorf("attempts = %d, want 1", got) + } +} + +// The per-attempt context is derived from the caller's, so it can only shorten +// the operation, never extend it. +func TestRetry_AttemptTimeoutDoesNotExtendCallerDeadline(t *testing.T) { + var attempts atomic.Int64 + + c := rhttp.New( + rhttp.WithTransport(unresponsiveTransport(&attempts)), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + AttemptTimeout: 5 * time.Second, + Backoff: rhttp.ConstantBackoff(10 * time.Millisecond), + })), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + + start := time.Now() + if _, err := c.Do(ctx, req); err == nil { + t.Fatal("expected an error from the unresponsive transport") + } + + if elapsed := time.Since(start); elapsed > time.Second { + t.Errorf("the call took %v: AttemptTimeout must not outlive the caller's deadline", elapsed) + } +} + +// The per-attempt context is released through the response body, so a response +// handed back to the caller must still be readable. +func TestRetry_AttemptTimeoutLastAttemptBodyReadable(t *testing.T) { + rt := rhttp.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: &contextAwareBody{ctx: req.Context(), data: "payload"}, + Request: req, + }, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + AttemptTimeout: time.Second, + })), + ) + + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + resp, err := c.Do(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("reading the body: %v; the per-attempt context was canceled too early", err) + } + if string(body) != "payload" { + t.Errorf("body = %q, want %q", body, "payload") + } +} diff --git a/timeout.go b/timeout.go index a71e5a1..7516a40 100644 --- a/timeout.go +++ b/timeout.go @@ -2,6 +2,7 @@ package rhttp import ( "context" + "fmt" "io" "net/http" "time" @@ -9,9 +10,15 @@ import ( // Timeout returns a middleware that applies a timeout to requests. // If the request's context already has a shorter deadline, it is respected. -// A non-positive duration disables the middleware (it becomes a no-op). +// +// A non-positive duration is not a timeout, so the middleware falls back to a +// pass-through. That fallback is reported through OnInvalidConfig: it leaves the +// request bounded only by the caller's context, and DefaultTransport bounds +// neither dialing nor the wait for response headers. func Timeout(d time.Duration) Middleware { if d <= 0 { + reportInvalidConfig("Timeout", fmt.Sprintf( + "duration is %v: requests are not bounded by this middleware", d)) return func(next http.RoundTripper) http.RoundTripper { return next } diff --git a/web/docs/assets/banner.svg b/web/docs/assets/banner.svg new file mode 100644 index 0000000..c744392 --- /dev/null +++ b/web/docs/assets/banner.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + GO · ZERO DEPENDENCIES + rhttp + Resilient HTTP client for Go + + + + + Timeout + + Retry + + Circuit Breaker + + Rate Limit + + + + + + + + + + + + diff --git a/web/docs/assets/logo.svg b/web/docs/assets/logo.svg new file mode 100644 index 0000000..e7b59ca --- /dev/null +++ b/web/docs/assets/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/docs/getting-started/installation.es.md b/web/docs/getting-started/installation.es.md new file mode 100644 index 0000000..21482ed --- /dev/null +++ b/web/docs/getting-started/installation.es.md @@ -0,0 +1,50 @@ +# Instalación + +## Requisitos + +Go 1.21 o superior. Nada más: rhttp solo depende de la biblioteca estándar. + +## Instalar + +```sh +go get github.com/oswaldom-code/rhttp +``` + +## Importar + +```go +import "github.com/oswaldom-code/rhttp" +``` + +El nombre del paquete es `rhttp`. + +## Verificar + +```go +package main + +import ( + "context" + "fmt" + + "github.com/oswaldom-code/rhttp" +) + +func main() { + client := rhttp.New() + + resp, err := client.R(). + Context(context.Background()). + Get("https://httpbin.org/get") + if err != nil { + panic(err) + } + defer resp.Body.Close() + + fmt.Println(resp.Status) +} +``` + +`rhttp.New()` sin opciones te da un transport optimizado con valores por defecto +razonables. La resiliencia se añade con middleware — mira el +[Inicio rápido](quickstart.md). diff --git a/web/docs/getting-started/installation.md b/web/docs/getting-started/installation.md new file mode 100644 index 0000000..54977d2 --- /dev/null +++ b/web/docs/getting-started/installation.md @@ -0,0 +1,49 @@ +# Installation + +## Requirements + +Go 1.21 or later. Nothing else: rhttp depends only on the standard library. + +## Install + +```sh +go get github.com/oswaldom-code/rhttp +``` + +## Import + +```go +import "github.com/oswaldom-code/rhttp" +``` + +The package name is `rhttp`. + +## Verify + +```go +package main + +import ( + "context" + "fmt" + + "github.com/oswaldom-code/rhttp" +) + +func main() { + client := rhttp.New() + + resp, err := client.R(). + Context(context.Background()). + Get("https://httpbin.org/get") + if err != nil { + panic(err) + } + defer resp.Body.Close() + + fmt.Println(resp.Status) +} +``` + +`rhttp.New()` without options gives you an optimized transport with sane defaults. +Add resiliency with middleware — see the [Quickstart](quickstart.md). diff --git a/web/docs/getting-started/quickstart.es.md b/web/docs/getting-started/quickstart.es.md new file mode 100644 index 0000000..7cdc0b4 --- /dev/null +++ b/web/docs/getting-started/quickstart.es.md @@ -0,0 +1,62 @@ +# Inicio rápido + +## Construir un cliente + +Un `Client` se configura una sola vez mediante opciones funcionales y es seguro +para uso concurrente. El middleware se ejecuta en el orden en que se lista: el +primero es el más externo. + +```go +client := rhttp.New( + rhttp.WithMiddleware( + rhttp.Timeout(5*time.Second), + rhttp.Retry(rhttp.RetryConfig{MaxAttempts: 3}), + rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 5, + ResetTimeout: 30 * time.Second, + }), + ), +) +``` + +El orden completo recomendado es: + +``` +Logging → Metrics → Timeout → RateLimit → Retry → CircuitBreaker +``` + +Retry queda por fuera del circuit breaker para que cada intento consulte el +circuito: un breaker abierto corta los intentos restantes. + +## Hacer peticiones + +Usa `client.Do(ctx, req)` con un `*http.Request` normal, o el builder fluido: + +```go +resp, err := client.R(). + Context(ctx). + SetHeader("X-Request-ID", id). + SetQueryParam("page", "2"). + SetPathParam("id", "42"). + SetBodyJSON(payload). + Post("https://api.example.com/users/{id}/orders") +``` + +El builder cubre cabeceras, autenticación (`SetAuthToken`, `SetBasicAuth`), +parámetros de query y de ruta, timeout por petición (`SetTimeout`) y cuerpos +JSON/XML/form. Los cuerpos que se pasan como `io.Reader` se bufferizan hasta +10 MB para que los reintentos puedan reproducirlos; los más grandes se +transmiten una sola vez y no se reintentan. + +## Decodificar respuestas + +```go +var user User +if err := rhttp.DecodeJSON(resp, &user); err != nil { + return err +} +``` + +`DecodeJSON` decodifica en streaming, siempre drena y cierra el cuerpo — +manteniendo las conexiones reutilizables— y devuelve error para códigos de +estado >= 300. diff --git a/web/docs/getting-started/quickstart.md b/web/docs/getting-started/quickstart.md new file mode 100644 index 0000000..b97fac6 --- /dev/null +++ b/web/docs/getting-started/quickstart.md @@ -0,0 +1,59 @@ +# Quickstart + +## Build a client + +A `Client` is configured once with functional options and is safe for concurrent +use. Middleware runs in the order listed — first is outermost. + +```go +client := rhttp.New( + rhttp.WithMiddleware( + rhttp.Timeout(5*time.Second), + rhttp.Retry(rhttp.RetryConfig{MaxAttempts: 3}), + rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 5, + ResetTimeout: 30 * time.Second, + }), + ), +) +``` + +The recommended full order is: + +``` +Logging → Metrics → Timeout → RateLimit → Retry → CircuitBreaker +``` + +Retry sits outside the circuit breaker so every attempt consults the circuit — a +tripped breaker cuts the remaining attempts. + +## Make requests + +Use `client.Do(ctx, req)` with a plain `*http.Request`, or the fluent builder: + +```go +resp, err := client.R(). + Context(ctx). + SetHeader("X-Request-ID", id). + SetQueryParam("page", "2"). + SetPathParam("id", "42"). + SetBodyJSON(payload). + Post("https://api.example.com/users/{id}/orders") +``` + +The builder covers headers, auth (`SetAuthToken`, `SetBasicAuth`), query and path +params, per-request timeout (`SetTimeout`), and JSON/XML/form bodies. Bodies passed +as an `io.Reader` are buffered up to 10 MB so retries can replay them; larger +bodies stream once and are not retried. + +## Decode responses + +```go +var user User +if err := rhttp.DecodeJSON(resp, &user); err != nil { + return err +} +``` + +`DecodeJSON` streams the decode, always drains and closes the body (keeping +connections reusable), and returns an error for status codes >= 300. diff --git a/web/docs/index.es.md b/web/docs/index.es.md new file mode 100644 index 0000000..69dd9e4 --- /dev/null +++ b/web/docs/index.es.md @@ -0,0 +1,134 @@ +rhttp — cliente HTTP resiliente para Go, cero dependencias + +# Resiliencia integrada en cada petición { .rh-sr-only } + +**rhttp** es un cliente HTTP para Go con reintentos, circuit breaking, rate limiting y +timeouts compuestos como una cadena de middleware transparente — con cero dependencias +externas y sobrecosto casi nulo. + +[Empezar](getting-started/installation.md){ .md-button .md-button--primary } +[Ver en GitHub](https://github.com/oswaldom-code/rhttp){ .md-button } + +## Instalación + +```sh +go get github.com/oswaldom-code/rhttp +``` + +Requiere Go 1.21+. Sin dependencias transitivas: lo que auditas es lo que despliegas. + +## Inicio rápido + +Construyes el cliente una vez y describes las peticiones de forma fluida. Cada llamada +atraviesa la cadena resiliente completa. + +=== "Cliente resiliente" + + ```go + client := rhttp.New( + rhttp.WithMiddleware( + rhttp.Timeout(5*time.Second), + rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + Backoff: rhttp.WithRetryAfter( + rhttp.ExponentialBackoff(100*time.Millisecond, 2*time.Second), + ), + }), + rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 5, + }), + ), + ) + ``` + +=== "Petición fluida" + + ```go + resp, err := client.R(). + Context(ctx). + SetPathParam("id", "42"). + SetAccept("application/json"). + Get("https://api.example.com/users/{id}") + if err != nil { + return err + } + + var user User + if err := rhttp.DecodeJSON(resp, &user); err != nil { + return err + } + ``` + +## La cadena de middleware + +Cada capacidad es una simple `func(http.RoundTripper) http.RoundTripper`. Componlas +en el orden recomendado — o trae las tuyas. + +
+ Logging + Metrics + Timeout + RateLimit + Retry + CircuitBreaker + Transport +
+ +Retry envuelve al breaker, de modo que cada intento consulta el circuito: un breaker +abierto corta los intentos restantes en lugar de castigar a un host que ya está +caído. + +## Qué trae + +
+ +- **Reintentos inteligentes** + + Siete estrategias de backoff más decoradores, conscientes de `Retry-After`, con + cuerpos reproducibles. [Saber más](resiliency/retry.md) + +- **Circuit breaker** + + Recuperación con sonda única y una máquina de estados con generaciones: una + respuesta lenta y obsoleta nunca puede corromper la recuperación. + [Saber más](resiliency/circuit-breaker.md) + +- **Rate limiting** + + Token bucket a ~50ns por adquisición, cero asignaciones, modos esperar-o-fallar. + [Saber más](resiliency/rate-limiting.md) + +- **Builder fluido** + + Parámetros de ruta, query, autenticación, cuerpos JSON/XML/form — reintentables + hasta 10 MB. [Saber más](getting-started/quickstart.md) + +- **Observabilidad** + + Hooks conectables de [logging](observability/logging.md) y + [métricas](observability/metrics.md), transiciones del circuito en el momento en + que ocurren y [configuración inválida reportada al + arrancar](observability/diagnostics.md). + +- **Cero dependencias** + + Solo biblioteca estándar, más del 95% de cobertura de tests, limpio bajo el + detector de carreras. + +
+ +## Benchmarks + +Timeout, retry y circuit breaker sobre el nivel de transport simulado, que aísla el +sobrecosto del cliente del ruido de red. Media de 5 ejecuciones, linux/amd64, +toolchain de Go 1.24.1. + +| Cliente | tiempo/op relativo | allocs/op | +| ----------------- | -----------------: | --------: | +| **rhttp** | **1.00×** | **10** | +| net/http + retry | 2.01× | 26 | +| retryablehttp | 2.10× | 26 | +| heimdall | 3.13× | 32 | +| resty | 7.04× | 48 | + +Metodología y advertencias en el [informe completo](reference/benchmarks.md). diff --git a/web/docs/index.md b/web/docs/index.md new file mode 100644 index 0000000..365c51d --- /dev/null +++ b/web/docs/index.md @@ -0,0 +1,131 @@ +rhttp — resilient HTTP client for Go, zero dependencies + +# Resiliency built into every request { .rh-sr-only } + +**rhttp** is an HTTP client for Go with retries, circuit breaking, rate limiting and +timeouts composed as a transparent middleware chain — with zero external dependencies +and near-zero overhead. + +[Get started](getting-started/installation.md){ .md-button .md-button--primary } +[View on GitHub](https://github.com/oswaldom-code/rhttp){ .md-button } + +## Install + +```sh +go get github.com/oswaldom-code/rhttp +``` + +Requires Go 1.21+. No transitive dependencies: what you audit is what you ship. + +## Quickstart + +Build a client once, describe requests fluently. Every call goes through the full +resilient chain. + +=== "Resilient client" + + ```go + client := rhttp.New( + rhttp.WithMiddleware( + rhttp.Timeout(5*time.Second), + rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + Backoff: rhttp.WithRetryAfter( + rhttp.ExponentialBackoff(100*time.Millisecond, 2*time.Second), + ), + }), + rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 5, + }), + ), + ) + ``` + +=== "Fluent request" + + ```go + resp, err := client.R(). + Context(ctx). + SetPathParam("id", "42"). + SetAccept("application/json"). + Get("https://api.example.com/users/{id}") + if err != nil { + return err + } + + var user User + if err := rhttp.DecodeJSON(resp, &user); err != nil { + return err + } + ``` + +## The middleware chain + +Every capability is a plain `func(http.RoundTripper) http.RoundTripper`. Compose them +in the recommended order — or bring your own. + +
+ Logging + Metrics + Timeout + RateLimit + Retry + CircuitBreaker + Transport +
+ +Retry wraps the breaker, so every attempt consults the circuit: a tripped breaker +short-circuits the remaining attempts instead of hammering a host that is already +down. + +## What's in the box + +
+ +- **Smart retries** + + Seven backoff strategies plus decorators, `Retry-After` aware, replay-safe + bodies. [Learn more](resiliency/retry.md) + +- **Circuit breaker** + + Single-probe recovery and a generation-gated state machine: slow stale + responses can never corrupt recovery. [Learn more](resiliency/circuit-breaker.md) + +- **Rate limiting** + + Token bucket at ~50ns per acquire, zero allocations, wait-or-fail modes. + [Learn more](resiliency/rate-limiting.md) + +- **Fluent builder** + + Path params, query, auth, JSON/XML/form bodies — retryable up to 10 MB. + [Learn more](getting-started/quickstart.md) + +- **Observability** + + Pluggable [logging](observability/logging.md) and + [metrics](observability/metrics.md) hooks, circuit transitions as they happen, + and [misconfiguration reported at startup](observability/diagnostics.md). + +- **Zero dependencies** + + Standard library only, 95%+ test coverage, race-detector clean. + +
+ +## Benchmarks + +Timeout, retry and circuit breaker on the mock-transport tier, which isolates +client overhead from network noise. Mean of 5 runs, linux/amd64, Go 1.24.1 +toolchain. + +| Client | time/op relative | allocs/op | +| ----------------- | ---------------: | --------: | +| **rhttp** | **1.00×** | **10** | +| net/http + retry | 2.01× | 26 | +| retryablehttp | 2.10× | 26 | +| heimdall | 3.13× | 32 | +| resty | 7.04× | 48 | + +Methodology and caveats in the [full report](reference/benchmarks.md). diff --git a/web/docs/observability/diagnostics.es.md b/web/docs/observability/diagnostics.es.md new file mode 100644 index 0000000..98b0f56 --- /dev/null +++ b/web/docs/observability/diagnostics.es.md @@ -0,0 +1,57 @@ +# Configuración inválida + +Un middleware al que se le da una configuración que no puede aplicar no falla en +la construcción: devuelve el `RoundTripper` siguiente sin tocarlo. `Timeout(0)` no +puede acotar nada, `RateLimit{Limiter: nil}` no tiene qué consultar, +`NewTokenBucket(0, …)` entraría en espera activa o bloquearía para siempre. +Degradar a un paso directo es la respuesta segura en los tres casos. + +El problema no es la degradación, sino que antes era silenciosa. Un cliente al que +le falta una protección se ve exactamente igual que uno bien configurado, hasta +el día en que esa protección hacía falta. + +## OnInvalidConfig + +```go +func init() { + rhttp.OnInvalidConfig = func(component, reason string) { + log.Printf("rhttp: %s está inerte: %s", component, reason) + } +} +``` + +El hook convierte una protección ausente en una señal de arranque, en vez de en +un hallazgo durante el incidente. Es nil por defecto, así que la degradación +sigue tan callada como en versiones anteriores hasta que decides activarlo. + +Asígnalo **una sola vez durante el arranque, antes de construir cualquier cliente +o limitador**. Es una variable de paquete corriente: mutarla mientras otra +goroutine construye middleware es una carrera de datos. + +## Qué se reporta y qué calla + +| Componente | Se reporta cuando | +| ------------- | ---------------------------------------- | +| `Timeout` | La duración no es positiva | +| `RateLimit` | `Limiter` es nil | +| `TokenBucket` | `rate <= 0` o `burst < 1` | + +Solo reportan aquí los componentes cuya ausencia pierde una protección. Un +`MetricsConfig.Recorder` o un `LoggingConfig.Logger` nil significan +"observabilidad no configurada" — un valor por defecto legítimo que no pierde +nada, así que callan por diseño. + +## Fallar en lugar de degradar + +Para el rate limiter, donde los números suelen venir de configuración que podría +estar mal, hay una alternativa más estricta que devuelve el caso inválido como +error en vez de un bucket que no limita: + +```go +limiter, err := rhttp.NewTokenBucketE(cfg.Rate, cfg.Burst) +if err != nil { + return err // errors.Is(err, rhttp.ErrInvalidRateLimit) +} +``` + +Consulta [Rate limiting](../resiliency/rate-limiting.md#cuando-los-numeros-son-invalidos). diff --git a/web/docs/observability/diagnostics.md b/web/docs/observability/diagnostics.md new file mode 100644 index 0000000..856d865 --- /dev/null +++ b/web/docs/observability/diagnostics.md @@ -0,0 +1,57 @@ +# Invalid configuration + +A middleware given configuration it cannot apply does not fail construction: it +returns the next `RoundTripper` unchanged. `Timeout(0)` cannot bound anything, +`RateLimit{Limiter: nil}` has nothing to consult, `NewTokenBucket(0, …)` would +busy-loop or block forever. Falling back to a pass-through is the safe answer to +all three. + +The problem is not the fallback — it is that it used to be silent. A client +missing a protection looks exactly like a correctly configured one, right up to +the day the protection was needed. + +## OnInvalidConfig + +```go +func init() { + rhttp.OnInvalidConfig = func(component, reason string) { + log.Printf("rhttp: %s is inert: %s", component, reason) + } +} +``` + +The hook turns a missing protection into a startup signal instead of an incident +finding. It is nil by default, so the fallback stays as quiet as it was in +earlier versions until you opt in. + +Assign it **once during startup, before constructing any client or limiter**. It +is a plain package variable: mutating it while another goroutine builds +middleware is a data race. + +## What reports, and what stays silent + +| Component | Reported when | +| ------------- | ----------------------------------------- | +| `Timeout` | The duration is non-positive | +| `RateLimit` | `Limiter` is nil | +| `TokenBucket` | `rate <= 0` or `burst < 1` | + +Only components whose absence loses a protection report here. A nil +`MetricsConfig.Recorder` or `LoggingConfig.Logger` means "observability not +configured" — a legitimate default that loses nothing, so it stays silent by +design. + +## Failing instead of degrading + +For the rate limiter, where the numbers usually come from configuration that +could be wrong, there is a stricter alternative that returns the invalid case as +an error rather than a bucket that does not limit: + +```go +limiter, err := rhttp.NewTokenBucketE(cfg.Rate, cfg.Burst) +if err != nil { + return err // errors.Is(err, rhttp.ErrInvalidRateLimit) +} +``` + +See [Rate limiting](../resiliency/rate-limiting.md#when-the-numbers-are-invalid). diff --git a/web/docs/observability/logging.es.md b/web/docs/observability/logging.es.md new file mode 100644 index 0000000..0ae7cda --- /dev/null +++ b/web/docs/observability/logging.es.md @@ -0,0 +1,45 @@ +# Logging + +## Configuración + +```go +rhttp.Logging(rhttp.LoggingConfig{ + Logger: myLogger, + ShouldLog: func(req *http.Request, resp *http.Response, err error) bool { + return err != nil || resp.StatusCode >= 400 // solo errores + }, +}) +``` + +| Campo | Significado | +| ----------- | ------------------------------------------------------------- | +| `Logger` | Destino de las entradas de log (obligatorio) | +| `ShouldLog` | Predicado de filtrado; si es nil, se registra toda petición | + +## La interfaz Logger + +```go +type Logger interface { + Log(entry LogEntry) +} +``` + +`LogEntry` lleva el método, la URL, el estado, la duración y el error del +intercambio. Cualquier función puede actuar como logger mediante `LoggerFunc`, +lo que reduce la adaptación de `log/slog` a unas pocas líneas: + +```go +logger := rhttp.LoggerFunc(func(e rhttp.LogEntry) { + slog.Info("http", + "method", e.Method, + "url", e.URL, + "status", e.StatusCode, + "duration", e.Duration, + "err", e.Error, + ) +}) +``` + +Coloca Logging primero en la cadena para que observe el desenlace final del +intercambio completo —incluidos reintentos y cortes del circuit breaker— como +una sola entrada. diff --git a/web/docs/observability/logging.md b/web/docs/observability/logging.md new file mode 100644 index 0000000..d14b10d --- /dev/null +++ b/web/docs/observability/logging.md @@ -0,0 +1,45 @@ +# Logging + +## Configuration + +```go +rhttp.Logging(rhttp.LoggingConfig{ + Logger: myLogger, + ShouldLog: func(req *http.Request, resp *http.Response, err error) bool { + return err != nil || resp.StatusCode >= 400 // errors only + }, +}) +``` + +| Field | Meaning | +| ----------- | -------------------------------------------------- | +| `Logger` | Destination for log entries (required) | +| `ShouldLog` | Filter predicate; if nil, every request is logged | + +## The Logger interface + +```go +type Logger interface { + Log(entry LogEntry) +} +``` + +`LogEntry` carries the method, URL, status, duration and error of the exchange. +Any function can be a logger via `LoggerFunc`, which makes adapting `log/slog` +a few lines: + +```go +logger := rhttp.LoggerFunc(func(e rhttp.LogEntry) { + slog.Info("http", + "method", e.Method, + "url", e.URL, + "status", e.StatusCode, + "duration", e.Duration, + "err", e.Error, + ) +}) +``` + +Place Logging first in the chain so it observes the final outcome of the whole +exchange — including retries and circuit-breaker short-circuits — as a single +entry. diff --git a/web/docs/observability/metrics.es.md b/web/docs/observability/metrics.es.md new file mode 100644 index 0000000..f9b248f --- /dev/null +++ b/web/docs/observability/metrics.es.md @@ -0,0 +1,58 @@ +# Métricas + +## Configuración + +```go +rhttp.Metrics(rhttp.MetricsConfig{ + Recorder: myRecorder, + PathNormalizer: func(path string) string { + return normalize(path) // p. ej. /users/8f3a → /users/:id + }, +}) +``` + +| Campo | Significado | +| ---------------- | ------------------------------------------------------------------ | +| `Recorder` | Sumidero de eventos de métrica (obligatorio) | +| `PathNormalizer` | Mapea rutas crudas a etiquetas de cardinalidad acotada (ver abajo) | + +## La interfaz MetricsRecorder + +```go +type MetricsRecorder interface { + RecordRequest(event MetricEvent) +} +``` + +`MetricEvent` lleva método, host, ruta normalizada, código de estado y duración — +suficiente para construir métricas RED (rate, errors, duration) en Prometheus, +OpenTelemetry o lo que uses. `MetricsRecorderFunc` adapta una función corriente. + +## Etiquetar los fallos + +`MetricEvent.Error` es el error crudo. Pásalo por `Classify` para obtener una +etiqueta acotada y de baja cardinalidad: + +```go +rhttp.MetricsRecorderFunc(func(e rhttp.MetricEvent) { + kind := "none" + if e.Error != nil { + kind = rhttp.Classify(e.Error).Kind.String() // timeout, dns, circuit_open, … + } + counter.WithLabelValues(e.Method, kind).Inc() +}) +``` + +En el orden recomendado, Metrics se sitúa **por encima** del circuit breaker y +del rate limiter, así que sus rechazos llegan al recolector como errores +ordinarios. Se clasifican como `circuit_open` y `rate_limited`: el cliente +protegiéndose a sí mismo, que es un hecho distinto de una dependencia que falló, +y merece una etiqueta distinta. + +## Cardinalidad de las etiquetas + +Rutas crudas como `/users/8f3a/orders/2941` crearían una serie temporal por cada +identificador y harían estallar tu backend de métricas. Para eso existe +`PathNormalizer`: si es nil, `MetricEvent.Path` se emite **vacío** (seguro por +defecto); proporciona un normalizador que colapse los segmentos variables para +emitir una plantilla acotada como `/users/:id/orders/:id`. diff --git a/web/docs/observability/metrics.md b/web/docs/observability/metrics.md new file mode 100644 index 0000000..44c10c0 --- /dev/null +++ b/web/docs/observability/metrics.md @@ -0,0 +1,57 @@ +# Metrics + +## Configuration + +```go +rhttp.Metrics(rhttp.MetricsConfig{ + Recorder: myRecorder, + PathNormalizer: func(path string) string { + return normalize(path) // e.g. /users/8f3a → /users/:id + }, +}) +``` + +| Field | Meaning | +| ---------------- | ------------------------------------------------------------- | +| `Recorder` | Sink for metric events (required) | +| `PathNormalizer` | Maps raw paths to bounded-cardinality labels (see below) | + +## The MetricsRecorder interface + +```go +type MetricsRecorder interface { + RecordRequest(event MetricEvent) +} +``` + +`MetricEvent` carries method, host, normalized path, status code and duration — +enough to build RED metrics (rate, errors, duration) in Prometheus, OpenTelemetry +or anything else. `MetricsRecorderFunc` adapts a plain function. + +## Labelling failures + +`MetricEvent.Error` is the raw error. Pass it through `Classify` to get a +bounded, low-cardinality label: + +```go +rhttp.MetricsRecorderFunc(func(e rhttp.MetricEvent) { + kind := "none" + if e.Error != nil { + kind = rhttp.Classify(e.Error).Kind.String() // timeout, dns, circuit_open, … + } + counter.WithLabelValues(e.Method, kind).Inc() +}) +``` + +In the recommended order Metrics sits **above** the circuit breaker and the rate +limiter, so their refusals reach the recorder as ordinary errors. They classify +as `circuit_open` and `rate_limited` — the client protecting itself, which is a +different fact from a dependency that failed, and worth a different label. + +## Label cardinality + +Raw URL paths like `/users/8f3a/orders/2941` would create one time series per ID +and blow up your metrics backend. That is why `PathNormalizer` exists: if nil, +`MetricEvent.Path` is emitted **empty** (safe by default); provide a normalizer +that collapses variable segments to emit a bounded template such as +`/users/:id/orders/:id`. diff --git a/web/docs/reference/architecture.es.md b/web/docs/reference/architecture.es.md new file mode 100644 index 0000000..ba3d44d --- /dev/null +++ b/web/docs/reference/architecture.es.md @@ -0,0 +1,72 @@ +# Arquitectura + +rhttp es una capa fina de composición sobre `net/http`: cada capacidad es un +decorador de `http.RoundTripper`, y el cliente es la composición funcional del +middleware que elijas. + +```mermaid +flowchart TB + subgraph ClientLayer["Capa de cliente"] + C[Client] + RB[RequestBuilder] + end + + subgraph Chain["Cadena de middleware (orden recomendado)"] + direction TB + LOG[Logging] --> MET[Metrics] --> TO[Timeout] --> RL[RateLimit] --> RT[Retry] --> CB[CircuitBreaker] + end + + subgraph Core["Núcleo"] + TR[Transport] + EC[Clasificador de errores] + end + + C --> RB + C --> LOG + CB --> TR + TR --> S[(Servidor HTTP)] + RT -.-> EC + CB -.-> EC +``` + +## Decisiones clave + +**Middleware antes que configuración.** En vez de un cliente monolítico con +banderas de funcionalidad, cada preocupación es una +`func(http.RoundTripper) http.RoundTripper` independiente. Pagas solo por lo que +compones, y cualquier cosa que implemente la interfaz estándar —incluido tu +propio middleware— encaja en la cadena. + +**Retry por fuera del breaker.** Cada intento de reintento consulta el circuito. +Cuando el breaker se abre a mitad de la secuencia, `ErrCircuitOpen` (no +reintentable) corta los intentos restantes en lugar de encolar sondas inútiles +contra un host muerto. + +**La configuración inválida degrada, y lo dice.** Un middleware con una +configuración inválida devuelve el RoundTripper siguiente sin tocarlo. La +construcción nunca falla, y la mala configuración degrada a "función apagada" en +vez de a un cliente roto — pero una protección ausente en silencio se descubre +durante el incidente que debía evitar, así que la degradación se anuncia mediante +[`OnInvalidConfig`](../observability/diagnostics.md). + +**Clasificación de errores, no comparación de centinelas.** Los errores se +clasifican en tipos (`ErrKindTimeout`, `ErrKindDNS`, `ErrKindDNSNotFound`, +`ErrKindConnection`, `ErrKindTLS`, `ErrKindCanceled`, más `ErrKindCircuitOpen` y +`ErrKindRateLimited` para los rechazos del propio cliente) que alimentan los +predicados de reintento y de fallo. El tipo carga con el veredicto de reintento, +de modo que un fallo permanente como NXDOMAIN queda separado de su contraparte +transitoria en el momento de clasificar, en vez de parcheado dentro del predicado +de reintento. Clasificar cuesta ~8ns sin asignaciones. + +**La petición del llamante nunca se muta.** `Do` clona la petición antes de que +entre en la cadena; el middleware opera sobre el clon. El contrato de +`http.RoundTripper` se respeta de principio a fin. + +## Disciplina de rendimiento + +Cada componente de la ruta caliente tiene un presupuesto de asignaciones que los +benchmarks hacen cumplir: las estrategias de backoff y la clasificación de +errores son de cero asignaciones, adquirir un token cuesta ~50ns, y la cadena +completa de seis middleware cuesta 12 asignaciones por petición frente a las 4 de +un cliente pelado. Las regresiones no pasan revisión — mira +[Benchmarks](benchmarks.md). diff --git a/web/docs/reference/architecture.md b/web/docs/reference/architecture.md new file mode 100644 index 0000000..0f46e4a --- /dev/null +++ b/web/docs/reference/architecture.md @@ -0,0 +1,68 @@ +# Architecture + +rhttp is a thin composition layer over `net/http`: every capability is an +`http.RoundTripper` decorator, and the client is the function composition of the +middleware you choose. + +```mermaid +flowchart TB + subgraph ClientLayer["Client layer"] + C[Client] + RB[RequestBuilder] + end + + subgraph Chain["Middleware chain (recommended order)"] + direction TB + LOG[Logging] --> MET[Metrics] --> TO[Timeout] --> RL[RateLimit] --> RT[Retry] --> CB[CircuitBreaker] + end + + subgraph Core["Core"] + TR[Transport] + EC[Error classifier] + end + + C --> RB + C --> LOG + CB --> TR + TR --> S[(HTTP server)] + RT -.-> EC + CB -.-> EC +``` + +## Key decisions + +**Middleware over configuration.** Instead of a monolithic client with feature +flags, each concern is an independent `func(http.RoundTripper) http.RoundTripper`. +You pay only for what you compose, and anything that implements the standard +interface — including your own middleware — slots into the chain. + +**Retry outside the breaker.** Every retry attempt consults the circuit. When the +breaker trips mid-sequence, `ErrCircuitOpen` (non-retryable) cuts the remaining +attempts instead of queueing useless probes against a dead host. + +**Invalid config falls back, and says so.** A middleware given an invalid +configuration returns the next RoundTripper unchanged. Construction never fails, +and misconfiguration degrades to "feature off" rather than a broken client — but +a protection that is absent in silence is discovered during the incident it was +meant to prevent, so the fallback is announced through +[`OnInvalidConfig`](../observability/diagnostics.md). + +**Error classification, not sentinel matching.** Errors are classified into kinds +(`ErrKindTimeout`, `ErrKindDNS`, `ErrKindDNSNotFound`, `ErrKindConnection`, +`ErrKindTLS`, `ErrKindCanceled`, plus `ErrKindCircuitOpen` and +`ErrKindRateLimited` for the client's own refusals) that drive the retry and +failure predicates. The kind carries the retry verdict, so a permanent failure +such as NXDOMAIN is separated from its transient counterpart at classification +time rather than patched into the retry predicate. Classification costs ~8ns +with zero allocations. + +**The caller's request is never mutated.** `Do` clones the request before it +enters the chain; middleware operate on the clone. The `http.RoundTripper` +contract is honored end to end. + +## Performance discipline + +Every hot-path component has an allocation budget enforced by benchmarks: backoff +strategies and error classification are zero-alloc, token acquire is ~50ns, and +the full six-middleware chain costs 12 allocs per request against the 4 of a bare +client. Regressions fail review — see [Benchmarks](benchmarks.md). diff --git a/web/docs/reference/benchmarks.es.md b/web/docs/reference/benchmarks.es.md new file mode 100644 index 0000000..b1e1de3 --- /dev/null +++ b/web/docs/reference/benchmarks.es.md @@ -0,0 +1,58 @@ +# Benchmarks + +Tres niveles, cada uno responde a una pregunta distinta. Todas las cifras están +medidas en linux/amd64 con el toolchain de Go 1.24.1, media de 5 ejecuciones. +rhttp en sí requiere **Go 1.21 o superior** — es lo que declara `go.mod` y contra +lo que corre CI (1.21, 1.22, 1.23); el toolchain del benchmark es simplemente el +que produjo estos números. + +## Comparativa — sobrecosto del cliente + +Timeout, retry y circuit breaker contra un transport simulado, de modo que solo +se mide el sobrecosto del cliente. La cadena se mantiene deliberadamente en lo +que las demás bibliotecas pueden expresar, y todos los clientes reciben la misma +configuración: timeout de 5s, 3 intentos, backoff exponencial de 100ms a 2s. + +| Cliente | tiempo/op medio | relativo | allocs/op | +| ----------------- | --------------: | -------: | --------: | +| **rhttp** | **~0.86µs** | 1.00× | 10 | +| net/http + retry | ~1.73µs | 2.01× | 26 | +| retryablehttp | ~1.81µs | 2.10× | 26 | +| heimdall | ~2.69µs | 3.13× | 32 | +| resty | ~6.06µs | 7.04× | 48 | + +## Extremo a extremo — servidor en loopback + +Contra un servidor HTTP real en loopback, rhttp con la cadena completa corre a +1.05–1.07× del suelo que marca `net/http` pelado (74 allocs/op). En este nivel +los resultados caen dentro de un ±10% de ruido entre ejecuciones — trata las +diferencias pequeñas de posición como empates. + +## Micro — presupuestos por componente + +| Componente | Coste | Asignaciones | +| ------------------------- | -------------- | -----------: | +| Estrategias de backoff | 1.6–11.6 ns/op | 0 | +| Adquisición de TokenBucket| ~50 ns/op | 0 | +| Clasificación de errores | ~8 ns/op | 0 | + +Más allá de la comparativa de tres middleware de arriba, la cadena completa de +seis (que añade rate limit, logging y metrics) cuesta 12 allocs/op frente a las 4 +de un cliente pelado — el presupuesto que vigila +`BenchmarkMiddlewareOverhead_AllMiddleware`. `AttemptTimeout` y `OnStateChange` +no añaden ninguna asignación mientras no se configuren. + +## Advertencias + +- Las cifras con transport simulado aíslan el sobrecosto del cliente; sobre una + red real, la latencia las eclipsa a todas. El punto es que la resiliencia de + rhttp es efectivamente gratis. +- Cada biblioteca se configuró de forma tan equivalente como permite su API; la + paridad exacta de funcionalidades es imposible (p. ej. no todas soportan + circuit breaking de forma nativa). +- Los números cambian con el hardware y la versión de Go. Reprodúcelos tú mismo: + +```sh +git clone https://github.com/oswaldom-code/rhttp +cd rhttp/benchmarks && make report +``` diff --git a/web/docs/reference/benchmarks.md b/web/docs/reference/benchmarks.md new file mode 100644 index 0000000..7115959 --- /dev/null +++ b/web/docs/reference/benchmarks.md @@ -0,0 +1,53 @@ +# Benchmarks + +Three tiers, each answering a different question. All numbers measured on +linux/amd64 with the Go 1.24.1 toolchain, mean of 5 runs. rhttp itself requires +**Go 1.21 or later** — that is what `go.mod` declares and what CI tests against +(1.21, 1.22, 1.23); the benchmark toolchain is just what produced these figures. + +## Comparative — client overhead + +Timeout, retry and circuit breaker against a mock transport, so only client +overhead is measured. The chain is deliberately kept to what the other libraries +can express, and every client gets the same settings: 5s timeout, 3 attempts, +exponential backoff 100ms–2s. + +| Client | mean time/op | relative | allocs/op | +| ----------------- | -----------: | -------: | --------: | +| **rhttp** | **~0.86µs** | 1.00× | 10 | +| net/http + retry | ~1.73µs | 2.01× | 26 | +| retryablehttp | ~1.81µs | 2.10× | 26 | +| heimdall | ~2.69µs | 3.13× | 32 | +| resty | ~6.06µs | 7.04× | 48 | + +## End-to-end — loopback server + +Against a real HTTP server on loopback, rhttp with the full chain runs at +1.05–1.07× of the bare `net/http` floor (74 allocs/op). At this tier results sit +within ±10% run-to-run noise — treat small rank differences as ties. + +## Micro — component budgets + +| Component | Cost | Allocations | +| -------------------- | -------------- | ----------: | +| Backoff strategies | 1.6–11.6 ns/op | 0 | +| TokenBucket acquire | ~50 ns/op | 0 | +| Error classification | ~8 ns/op | 0 | + +Beyond the three-middleware comparison above, the full six-middleware chain +(adding rate limit, logging and metrics) costs 12 allocs/op against the 4 of a +bare client — the budget `BenchmarkMiddlewareOverhead_AllMiddleware` guards. +`AttemptTimeout` and `OnStateChange` add no allocation while unset. + +## Caveats + +- Mock-transport numbers isolate client overhead; over a real network, latency + dwarfs all of them. The point is that rhttp's resiliency is effectively free. +- Each library was configured as equivalently as its API allows; exact feature + parity is impossible (e.g. not all support circuit breaking natively). +- Numbers move with hardware and Go versions. Reproduce them yourself: + +```sh +git clone https://github.com/oswaldom-code/rhttp +cd rhttp/benchmarks && make report +``` diff --git a/web/docs/resiliency/circuit-breaker.es.md b/web/docs/resiliency/circuit-breaker.es.md new file mode 100644 index 0000000..4cb417c --- /dev/null +++ b/web/docs/resiliency/circuit-breaker.es.md @@ -0,0 +1,106 @@ +# Circuit breaker + +## Configuración + +```go +rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 5, + ResetTimeout: 30 * time.Second, + MaxHalfOpenRequests: 1, + SuccessThreshold: 1, +}) +``` + +| Campo | Significado | +| --------------------- | --------------------------------------------------------------------------------- | +| `FailureThreshold` | Fallos consecutivos antes de que el circuito se abra | +| `ResetTimeout` | Tiempo en estado Open antes de sondear (Half-Open) | +| `IsFailure` | Predicado propio; por defecto: cualquier error o estado >= 500 | +| `MaxHalfOpenRequests` | Sondas concurrentes permitidas en Half-Open (por defecto 1 — sonda única garantizada) | +| `SuccessThreshold` | Sondas exitosas consecutivas necesarias para cerrar (por defecto 1) | +| `OnStateChange` | Callback invocado tras cada transición (por defecto: transiciones no observadas) | + +## Máquina de estados + +```mermaid +stateDiagram-v2 + [*] --> Closed + Closed --> Open: fallos >= umbral + Open --> HalfOpen: transcurrió ResetTimeout + HalfOpen --> Closed: SuccessThreshold éxitos + HalfOpen --> Open: cualquier fallo + Open --> Open: las peticiones fallan rápido (ErrCircuitOpen) +``` + +Mientras está abierto, las peticiones fallan de inmediato con +`rhttp.ErrCircuitOpen` — no se hace ninguna llamada de red. `ErrCircuitOpen` no +es reintentable, así que cuando Retry envuelve al breaker un circuito abierto +corta también los intentos restantes. `Classify` lo reporta como +`ErrKindCircuitOpen`, que es como un recolector de métricas situado por encima +del breaker distingue "el circuito rechazó esto" de un fallo real de transporte. + +## Observar las transiciones + +Usa `OnStateChange`; no hagas polling de `State()`: + +```go +rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 5, + ResetTimeout: 30 * time.Second, + OnStateChange: func(from, to rhttp.CircuitState) { + log.Printf("circuito %s -> %s", from, to) + }, +}) +``` + +Con el `SuccessThreshold` por defecto de 1, la fase Half-Open se entra y se +abandona dentro de un mismo `RoundTrip`, así que **ninguna frecuencia de muestreo +puede capturarla**: quien hace polling ve `closed → open → closed` y la +recuperación en sí —la parte que te dice si la dependencia volvió por su cuenta— +es invisible por construcción. El callback reporta en lugar de muestrear, de modo +que una transición de nanosegundos igual aparece. + +El callback se ejecuta en la goroutine de la petición que causó la transición, +con el mutex del breaker liberado, así que leer `State()` desde dentro es seguro. +No debe bloquear: la petición no puede avanzar hasta que retorne. Las transiciones +se publican en orden para un mismo flujo de peticiones; bajo concurrencia, dos +pueden publicarse en un orden distinto a aquel en que ocurrieron, precisamente +porque el mutex se libera antes. + +## Control por generación + +Cada transición de estado incrementa un contador de generación interno. Cada +petición admitida recuerda la generación bajo la que entró, y los resultados de +una generación obsoleta se descartan. Una petición lenta admitida en estado +Closed jamás puede corromper un episodio Half-Open posterior. + +## Acceder al breaker + +`CircuitBreaker(cfg)` crea un breaker por instancia del middleware y descarta el +manejador. Cuando necesitas la máquina de estados que acabas de configurar, usa +`CircuitBreakerWithState`: + +```go +mw, breaker := rhttp.CircuitBreakerWithState(cfg) + +client := rhttp.New(rhttp.WithMiddleware(mw)) +_ = breaker.State() // Closed / Open / Half-Open +``` + +## Compartir un breaker entre clientes + +Para que varios clientes se abran a la vez frente a la misma dependencia, crea el +breaker explícitamente y deriva middleware de él: + +```go +cb := rhttp.NewCircuitBreaker(cfg) + +clientA := rhttp.New(rhttp.WithMiddleware(cb.Middleware())) +clientB := rhttp.New(rhttp.WithMiddleware(cb.Middleware())) + +fmt.Println(cb.State()) +``` + +Todo el middleware derivado del mismo `SharedCircuitBreaker` observa un único +estado de circuito. Para publicar transiciones, sigue prefiriendo `OnStateChange` +antes que hacer polling de `State()`, por la razón de arriba. diff --git a/web/docs/resiliency/circuit-breaker.md b/web/docs/resiliency/circuit-breaker.md new file mode 100644 index 0000000..941197f --- /dev/null +++ b/web/docs/resiliency/circuit-breaker.md @@ -0,0 +1,105 @@ +# Circuit breaker + +## Configuration + +```go +rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 5, + ResetTimeout: 30 * time.Second, + MaxHalfOpenRequests: 1, + SuccessThreshold: 1, +}) +``` + +| Field | Meaning | +| --------------------- | ---------------------------------------------------------------------------- | +| `FailureThreshold` | Consecutive failures before the circuit opens | +| `ResetTimeout` | Time in Open state before probing (Half-Open) | +| `IsFailure` | Custom predicate; default: any error or status >= 500 | +| `MaxHalfOpenRequests` | Concurrent probes allowed in Half-Open (default 1 — guaranteed single probe) | +| `SuccessThreshold` | Consecutive successful probes required to close (default 1) | +| `OnStateChange` | Callback invoked after every transition (default: transitions unobserved) | + +## State machine + +```mermaid +stateDiagram-v2 + [*] --> Closed + Closed --> Open: failures >= threshold + Open --> HalfOpen: ResetTimeout elapsed + HalfOpen --> Closed: SuccessThreshold successes + HalfOpen --> Open: any failure + Open --> Open: requests fail fast (ErrCircuitOpen) +``` + +While open, requests fail immediately with `rhttp.ErrCircuitOpen` — no network +call is made. `ErrCircuitOpen` is not retryable, so when Retry wraps the breaker +a tripped circuit also cuts the remaining retry attempts. `Classify` reports it +as `ErrKindCircuitOpen`, which is how a metrics recorder above the breaker tells +"the circuit refused this" apart from a genuine transport failure. + +## Observing transitions + +Use `OnStateChange` — do not poll `State()`: + +```go +rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 5, + ResetTimeout: 30 * time.Second, + OnStateChange: func(from, to rhttp.CircuitState) { + log.Printf("circuit %s -> %s", from, to) + }, +}) +``` + +At the default `SuccessThreshold` of 1 the Half-Open phase is entered and left +inside a single `RoundTrip`, so **no polling frequency can sample it**: a poller +sees `closed → open → closed` and the recovery itself — the part that tells you +whether the dependency came back on its own — is invisible by construction. The +callback reports rather than samples, so a transition lasting nanoseconds still +shows up. + +The callback runs on the goroutine of the request that caused the transition, +with the breaker's mutex released, so reading `State()` from inside it is safe. +It must not block: the request cannot proceed until it returns. Transitions are +published in order for a single request stream; under concurrency two may be +published in an order different from the one in which they occurred, because the +mutex is released first. + +## Generation gating + +Every state transition bumps an internal generation counter. Each admitted request +remembers the generation it entered under, and results from a stale generation are +discarded. A slow request admitted while Closed can never corrupt a later +Half-Open episode. + +## Reaching the breaker itself + +`CircuitBreaker(cfg)` creates one breaker per middleware instance and discards +the handle. When you need the state machine you just configured, use +`CircuitBreakerWithState`: + +```go +mw, breaker := rhttp.CircuitBreakerWithState(cfg) + +client := rhttp.New(rhttp.WithMiddleware(mw)) +_ = breaker.State() // Closed / Open / Half-Open +``` + +## Sharing a breaker across clients + +To make several clients trip together against the same dependency, create the +breaker explicitly and derive middleware from it: + +```go +cb := rhttp.NewCircuitBreaker(cfg) + +clientA := rhttp.New(rhttp.WithMiddleware(cb.Middleware())) +clientB := rhttp.New(rhttp.WithMiddleware(cb.Middleware())) + +fmt.Println(cb.State()) +``` + +All middleware derived from the same `SharedCircuitBreaker` observe one circuit +state. For publishing transitions, still prefer `OnStateChange` over polling +`State()`, for the reason above. diff --git a/web/docs/resiliency/rate-limiting.es.md b/web/docs/resiliency/rate-limiting.es.md new file mode 100644 index 0000000..8b6fbce --- /dev/null +++ b/web/docs/resiliency/rate-limiting.es.md @@ -0,0 +1,63 @@ +# Rate limiting + +## Configuración + +```go +rhttp.RateLimit(rhttp.RateLimitConfig{ + Limiter: rhttp.NewTokenBucket(100, 20), // 100 req/s, ráfaga de 20 + WaitOnLimit: true, +}) +``` + +| Campo | Significado | +| ------------------- | -------------------------------------------------------------------- | +| `Limiter` | El `RateLimiter` a consultar (obligatorio) | +| `WaitOnLimit` | Esperar por un token en vez de fallar rápido (por defecto: fallar) | +| `RespectRetryAfter` | Honrar las cabeceras `Retry-After` de las respuestas | + +Con `WaitOnLimit: false`, una petición que no encuentra token falla de inmediato +con `rhttp.ErrRateLimited`. Con `true`, espera — respetando la cancelación del +contexto. `Classify` reporta el rechazo como `ErrKindRateLimited`: fue la propia +cuota del cliente la que rechazó la petición, que nunca llegó a la red. + +## Token bucket + +`NewTokenBucket(rate, burst)` implementa un token bucket clásico: `burst` tokens +disponibles de golpe, rellenados de forma continua a `rate` tokens por segundo. +Adquirir un token cuesta ~50ns sin asignaciones, así que el limitador no añade +sobrecosto medible a la ruta de la petición. + +## Cuando los números son inválidos + +Un rate no positivo o una ráfaga menor que 1 no pueden producir un limitador. En +lugar de entrar en espera activa o bloquear para siempre, `NewTokenBucket` +degrada a un bucket que admite todo — seguro, pero indistinguible de uno bien +configurado hasta que llega la carga que debía moderar. Esa degradación se +reporta a través de [`OnInvalidConfig`](../observability/diagnostics.md). + +Cuando los valores vienen de configuración que podría estar mal, usa +`NewTokenBucketE` y falla en el arranque: + +```go +limiter, err := rhttp.NewTokenBucketE(cfg.Rate, cfg.Burst) +if err != nil { + return err // errors.Is(err, rhttp.ErrInvalidRateLimit) +} +``` + +Un `RateLimitConfig` con `Limiter` nil es igualmente un paso directo, y se +reporta de la misma forma. + +## Trae tu propio limitador + +El middleware acepta cualquier implementación de: + +```go +type RateLimiter interface { + TryAcquire() bool + WaitContext(ctx context.Context) error +} +``` + +Esta es la costura para adaptar `golang.org/x/time/rate` o un limitador +distribuido sin que rhttp adopte la dependencia. diff --git a/web/docs/resiliency/rate-limiting.md b/web/docs/resiliency/rate-limiting.md new file mode 100644 index 0000000..86384ee --- /dev/null +++ b/web/docs/resiliency/rate-limiting.md @@ -0,0 +1,63 @@ +# Rate limiting + +## Configuration + +```go +rhttp.RateLimit(rhttp.RateLimitConfig{ + Limiter: rhttp.NewTokenBucket(100, 20), // 100 req/s, burst of 20 + WaitOnLimit: true, +}) +``` + +| Field | Meaning | +| ------------------- | ---------------------------------------------------------------- | +| `Limiter` | The `RateLimiter` to consult (required) | +| `WaitOnLimit` | Wait for a token instead of failing fast (default: fail fast) | +| `RespectRetryAfter` | Honor `Retry-After` headers from responses | + +With `WaitOnLimit: false`, a request that finds no token fails immediately with +`rhttp.ErrRateLimited`. With `true`, it waits — respecting context cancellation. +`Classify` reports the refusal as `ErrKindRateLimited`: the client's own quota +turned the request away, and it never reached the network. + +## Token bucket + +`NewTokenBucket(rate, burst)` implements a classic token bucket: `burst` tokens +available at once, refilled continuously at `rate` tokens per second. Acquiring a +token costs ~50ns with zero allocations, so the limiter adds no measurable +overhead to the request path. + +## When the numbers are invalid + +A non-positive rate or a burst below 1 cannot produce a limiter. Rather than +busy-loop or block forever, `NewTokenBucket` falls back to a bucket that admits +everything — safe, but indistinguishable from a correctly configured one until +the load it was meant to shape arrives. The fallback is reported through +[`OnInvalidConfig`](../observability/diagnostics.md). + +When the values come from configuration that could be wrong, use +`NewTokenBucketE` and fail at startup instead: + +```go +limiter, err := rhttp.NewTokenBucketE(cfg.Rate, cfg.Burst) +if err != nil { + return err // errors.Is(err, rhttp.ErrInvalidRateLimit) +} +``` + +A `RateLimitConfig` with a nil `Limiter` is likewise a pass-through, and is +reported the same way. + +## Bring your own limiter + +The middleware accepts any implementation of: + +```go +type RateLimiter interface { + TryAcquire() bool + WaitContext(ctx context.Context) error +} +``` + +This is the seam for adapting `golang.org/x/time/rate` or a distributed limiter +without rhttp taking the dependency. diff --git a/web/docs/resiliency/retry.es.md b/web/docs/resiliency/retry.es.md new file mode 100644 index 0000000..90f3678 --- /dev/null +++ b/web/docs/resiliency/retry.es.md @@ -0,0 +1,96 @@ +# Reintentos y backoff + +## Configuración + +```go +rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + Backoff: rhttp.WithRetryAfter( + rhttp.ExponentialBackoff(100*time.Millisecond, 2*time.Second), + ), +}) +``` + +| Campo | Significado | +| ----------------- | ---------------------------------------------------------------------------- | +| `MaxAttempts` | Número máximo de intentos, incluido el primero | +| `AttemptTimeout` | Plazo para cada intento individual (por defecto: sin acotar — ver abajo) | +| `Backoff` | `BackoffFunc` que decide la espera previa a cada reintento (por defecto exponencial) | +| `IsRetryable` | Predicado propio; la lógica por defecto está más abajo | +| `RetryAllMethods` | Reintentar también métodos no idempotentes (por defecto: solo idempotentes) | + +## Qué se reintenta por defecto + +- Errores clasificados como reintentables: timeouts, fallos de conexión y errores + de DNS transitorios. +- Códigos de estado `429`, `502`, `503` y `504`. Un `500` a secas **no** se + reintenta. +- Solo métodos idempotentes, salvo que se active `RetryAllMethods`. +- Solo peticiones cuyo cuerpo puede reproducirse (`GetBody` presente o sin cuerpo). + +La cancelación del contexto siempre gana: una petición cancelada nunca se +reintenta, ni siquiera durante una espera de backoff. + +## Acotar cada intento + +Un plazo en el contexto del llamante acota la **operación**, no cada intento. +Frente a una dependencia que se volvió lenta —en vez de una que falla rápido— el +primer intento consume todo el presupuesto y `MaxAttempts: 3` pone exactamente +una petición en el cable, mientras los logs y las métricas reportan un timeout +corriente, indistinguible de una secuencia de reintentos que legítimamente se +quedó sin tiempo. + +`AttemptTimeout` acota el intento en sí: + +```go +rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + AttemptTimeout: 2 * time.Second, // cada intento; el contexto sigue acotando el total +}) +``` + +El contexto por intento deriva del contexto del llamante, así que solo puede +acortar la operación, nunca extenderla. Cero —el valor por defecto— mantiene el +comportamiento anterior. + +!!! tip "Prefiérelo al orden de la cadena" + + Componer `Timeout` *por debajo* de `Retry` consigue lo mismo, pero esa es una + dependencia que ningún tipo expresa: si alguien reordena la cadena, la + garantía desaparece en silencio. `AttemptTimeout` la declara donde se lee. + +## Estrategias de backoff + +Todas las estrategias son libres de asignaciones y respetan una duración máxima: + +```go +rhttp.ConstantBackoff(500 * time.Millisecond) +rhttp.LinearBackoff(base, max) +rhttp.ExponentialBackoff(base, max) +rhttp.FibonacciBackoff(base, max) +rhttp.ExponentialBackoffFullJitter(base, max) +rhttp.ExponentialBackoffEqualJitter(base, max) +rhttp.DecorrelatedJitterBackoff(base, max) +``` + +Combínalas con decoradores: + +```go +rhttp.WithJitter(backoff, 0.2) // ±20% de jitter +rhttp.WithMin(backoff, min) +rhttp.WithMax(backoff, max) +rhttp.WithRetryAfter(backoff) // honra Retry-After en 429/503 +``` + +`WithRetryAfter` interpreta tanto la forma en segundos como la de fecha HTTP, y +espera `max(backoff, sugerencia del servidor)`. + +## BackoffFunc + +```go +type BackoffFunc func(attempt int, resp *http.Response) time.Duration +``` + +La función recibe la respuesta que disparó el reintento (`nil` si el intento no +produjo ninguna), que es justamente lo que habilita estrategias dirigidas por el +servidor como `WithRetryAfter`. diff --git a/web/docs/resiliency/retry.md b/web/docs/resiliency/retry.md new file mode 100644 index 0000000..f04ffdc --- /dev/null +++ b/web/docs/resiliency/retry.md @@ -0,0 +1,92 @@ +# Retry & backoff + +## Configuration + +```go +rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + Backoff: rhttp.WithRetryAfter( + rhttp.ExponentialBackoff(100*time.Millisecond, 2*time.Second), + ), +}) +``` + +| Field | Meaning | +| ----------------- | ----------------------------------------------------------------------- | +| `MaxAttempts` | Maximum attempts, including the first one | +| `AttemptTimeout` | Deadline for each individual attempt (default: unbounded — see below) | +| `Backoff` | `BackoffFunc` deciding the wait before each retry (default exponential) | +| `IsRetryable` | Custom predicate; default logic below | +| `RetryAllMethods` | Retry non-idempotent methods too (default: idempotent only) | + +## What gets retried by default + +- Errors classified as retryable: timeouts, connection failures, DNS errors. +- Status codes `429`, `502`, `503`, `504`. A plain `500` is **not** retried. +- Only idempotent methods, unless `RetryAllMethods` is set. +- Only requests whose body can be replayed (`GetBody` present or no body). + +Context cancellation always wins: a canceled request is never retried, including +during a backoff wait. + +## Bounding each attempt + +A deadline on the caller's context bounds the **operation**, not each attempt. +Against a dependency that became slow rather than one that fails fast, the first +attempt consumes the entire budget and `MaxAttempts: 3` puts exactly one request +on the wire — while the logs and metrics report a plain timeout, which looks +identical to a retry sequence that legitimately ran out of time. + +`AttemptTimeout` bounds the attempt itself: + +```go +rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + AttemptTimeout: 2 * time.Second, // each attempt; the context still caps the whole +}) +``` + +The per-attempt context derives from the caller's, so it can only shorten the +operation, never extend it. Zero — the default — keeps the previous behavior. + +!!! tip "Prefer it over middleware order" + + Composing `Timeout` *beneath* `Retry` achieves the same thing, but that is a + dependency no type expresses: reorder the chain and the guarantee silently + disappears. `AttemptTimeout` states it where it is read. + +## Backoff strategies + +All strategies are allocation-free and respect a maximum duration: + +```go +rhttp.ConstantBackoff(500 * time.Millisecond) +rhttp.LinearBackoff(base, max) +rhttp.ExponentialBackoff(base, max) +rhttp.FibonacciBackoff(base, max) +rhttp.ExponentialBackoffFullJitter(base, max) +rhttp.ExponentialBackoffEqualJitter(base, max) +rhttp.DecorrelatedJitterBackoff(base, max) +``` + +Compose them with decorators: + +```go +rhttp.WithJitter(backoff, 0.2) // ±20% jitter +rhttp.WithMin(backoff, min) +rhttp.WithMax(backoff, max) +rhttp.WithRetryAfter(backoff) // honor Retry-After on 429/503 +``` + +`WithRetryAfter` parses both delay-seconds and HTTP-date forms and waits for +`max(backoff, server hint)`. + +## BackoffFunc + +```go +type BackoffFunc func(attempt int, resp *http.Response) time.Duration +``` + +The function receives the response that triggered the retry (`nil` if the attempt +produced none), which is what enables server-driven strategies like +`WithRetryAfter`. diff --git a/web/docs/resiliency/timeouts.es.md b/web/docs/resiliency/timeouts.es.md new file mode 100644 index 0000000..8f8a13c --- /dev/null +++ b/web/docs/resiliency/timeouts.es.md @@ -0,0 +1,56 @@ +# Timeouts + +## Middleware + +```go +rhttp.Timeout(5 * time.Second) +``` + +Aplica un plazo a toda petición que lo atraviesa. Semántica: + +- Si el contexto de la petición ya lleva un plazo **más corto**, se respeta y el + middleware se aparta. +- El timeout cubre el intercambio completo **incluida la lectura del cuerpo**: el + contexto se cancela cuando haces `Close()` del cuerpo de la respuesta, no + antes. Leer un cuerpo grande pasado el plazo falla con + `context.DeadlineExceeded`. +- Una duración no positiva no puede acotar nada, así que el middleware degrada a + un paso directo. Configura + [`OnInvalidConfig`](../observability/diagnostics.md) para enterarte al arrancar + y no durante el incidente. + +## Timeout por petición + +El builder puede ajustar el plazo para una sola llamada: + +```go +resp, err := client.R(). + Context(ctx). + SetTimeout(800 * time.Millisecond). + Get("https://api.example.com/health") +``` + +## Interacción con Retry + +En el orden recomendado (`Timeout → Retry`) el timeout es un **presupuesto total** +para todos los intentos y sus esperas de backoff. Eso suele ser lo que quieres, +pero por sí solo deja cada intento sin acotar: una dependencia que se volvió +lenta deja que el primer intento consuma todo el presupuesto, y los reintentos +nunca ocurren. + +Acota el intento con +[`RetryConfig.AttemptTimeout`](retry.md#acotar-cada-intento), no colocando +`Timeout` por debajo de `Retry`: + +```go +rhttp.Timeout(5*time.Second), // presupuesto total +rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + AttemptTimeout: 2 * time.Second, // por intento +}), +``` + +Ambas cotas se componen: el contexto por intento deriva del contexto del +llamante, así que solo puede acortar la operación. La variante basada en el orden +del middleware logra lo mismo, pero no expresa la dependencia en ninguna parte y +revierte en silencio si se reordena la cadena. diff --git a/web/docs/resiliency/timeouts.md b/web/docs/resiliency/timeouts.md new file mode 100644 index 0000000..1003e6e --- /dev/null +++ b/web/docs/resiliency/timeouts.md @@ -0,0 +1,52 @@ +# Timeouts + +## Middleware + +```go +rhttp.Timeout(5 * time.Second) +``` + +Applies a deadline to every request that passes through it. Semantics: + +- If the request's context already carries a **shorter** deadline, it is + respected and the middleware steps aside. +- The timeout covers the whole exchange **including reading the body**: the + context is canceled when you `Close()` the response body, not before. Reading a + large body past the deadline fails with `context.DeadlineExceeded`. +- A non-positive duration cannot bound anything, so the middleware falls back to + a pass-through. Set [`OnInvalidConfig`](../observability/diagnostics.md) to + learn about it at startup instead of during the incident. + +## Per-request timeout + +The builder can tighten the deadline for a single call: + +```go +resp, err := client.R(). + Context(ctx). + SetTimeout(800 * time.Millisecond). + Get("https://api.example.com/health") +``` + +## Interaction with Retry + +In the recommended order (`Timeout → Retry`) the timeout is a **total budget** +for all attempts and their backoff waits. That is usually what you want, but on +its own it leaves each attempt unbounded: a dependency that became slow lets the +first attempt consume the whole budget, and the retries never happen. + +Bound the attempt with [`RetryConfig.AttemptTimeout`](retry.md#bounding-each-attempt), +not by placing `Timeout` beneath `Retry`: + +```go +rhttp.Timeout(5*time.Second), // total budget +rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + AttemptTimeout: 2 * time.Second, // per attempt +}), +``` + +Both bounds compose: the per-attempt context derives from the caller's, so it can +only shorten the operation. The middleware-order variant achieves the same thing +but expresses the dependency nowhere, and reverts silently if the chain is +reordered. diff --git a/web/docs/stylesheets/extra.css b/web/docs/stylesheets/extra.css new file mode 100644 index 0000000..8eb4989 --- /dev/null +++ b/web/docs/stylesheets/extra.css @@ -0,0 +1,115 @@ +/* rhttp theme — brand cyan from the logo, amber for breaker states, system fonts. */ + +:root { + --rh-accent: #007d9c; + --rh-accent-soft: rgba(0, 125, 156, 0.09); + --rh-amber: #b97613; + --rh-amber-soft: rgba(185, 118, 19, 0.1); +} + +[data-md-color-scheme="default"] { + --md-primary-fg-color: #007d9c; + --md-primary-fg-color--dark: #00647d; + --md-accent-fg-color: #00add8; + --md-typeset-a-color: #007d9c; + --md-default-bg-color: #f7fafb; +} + +[data-md-color-scheme="slate"] { + --rh-accent: #54d9f8; + --rh-accent-soft: rgba(84, 217, 248, 0.1); + --rh-amber: #e3a43f; + --rh-amber-soft: rgba(227, 164, 63, 0.12); + --md-primary-fg-color: #007d9c; + --md-accent-fg-color: #54d9f8; + --md-typeset-a-color: #54d9f8; + --md-default-bg-color: #0b1020; + --md-code-bg-color: #0a0e1a; +} + +/* Light header on both schemes, like the approved mockup. */ +.md-header { + background-color: var(--md-default-bg-color); + color: var(--md-default-fg-color); + box-shadow: 0 0 0.05rem rgba(0, 0, 0, 0.25), 0 0.05rem 0.3rem rgba(0, 0, 0, 0.08); +} + +.md-header__button, +.md-header__title, +.md-header__topic { + color: var(--md-default-fg-color); +} + +.md-header .md-logo img { + border-radius: 0.3rem; +} + +.md-search__input { + background-color: var(--md-default-fg-color--lightest); + color: var(--md-default-fg-color); +} + +.md-search__input::placeholder, +.md-search__input + .md-search__icon { + color: var(--md-default-fg-color--light); +} + +/* Home hero banner. */ +.rh-banner { + display: block; + width: 100%; + height: auto; + border-radius: 0.6rem; + margin: 0.4rem 0 1.4rem; +} + +.rh-sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +/* Middleware chain visual (home page). */ +.rh-chain { + display: flex; + align-items: center; + gap: 0.4rem; + overflow-x: auto; + padding: 0.4rem 0 0.7rem; +} + +.rh-chain .mw { + font-family: var(--md-code-font-family, monospace); + font-size: 0.68rem; + font-weight: 500; + padding: 0.32rem 0.6rem; + border-radius: 0.4rem; + border: 1px solid var(--md-default-fg-color--lightest); + white-space: nowrap; +} + +.rh-chain .mw.hot { + color: var(--rh-accent); + border-color: var(--rh-accent); + background: var(--rh-accent-soft); +} + +.rh-chain .mw.state { + color: var(--rh-amber); + border-color: var(--rh-amber); + background: var(--rh-amber-soft); +} + +.rh-chain .mw.end { + background: var(--md-default-fg-color); + color: var(--md-default-bg-color); + border-color: var(--md-default-fg-color); +} + +.rh-chain .arr { + color: var(--md-default-fg-color--light); + user-select: none; +} diff --git a/web/mkdocs.yml b/web/mkdocs.yml new file mode 100644 index 0000000..8786948 --- /dev/null +++ b/web/mkdocs.yml @@ -0,0 +1,103 @@ +site_name: rhttp +site_description: Production-grade HTTP client for Go with built-in resiliency patterns. Zero dependencies. +site_url: https://oswaldom-code.github.io/rhttp/ +repo_url: https://github.com/oswaldom-code/rhttp +repo_name: oswaldom-code/rhttp +edit_uri: "" + +theme: + name: material + logo: assets/logo.svg + favicon: assets/logo.svg + icon: + repo: fontawesome/brands/github + font: false + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/weather-night + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/weather-sunny + name: Switch to light mode + features: + - navigation.sections + - navigation.top + - navigation.footer + - search.suggest + - content.code.copy + - content.tabs.link + +nav: + - Home: index.md + - Getting started: + - Installation: getting-started/installation.md + - Quickstart: getting-started/quickstart.md + - Resiliency: + - Retry & backoff: resiliency/retry.md + - Circuit breaker: resiliency/circuit-breaker.md + - Rate limiting: resiliency/rate-limiting.md + - Timeouts: resiliency/timeouts.md + - Observability: + - Logging: observability/logging.md + - Metrics: observability/metrics.md + - Invalid configuration: observability/diagnostics.md + - Reference: + - Architecture: reference/architecture.md + - Benchmarks: reference/benchmarks.md + - API reference: https://pkg.go.dev/github.com/oswaldom-code/rhttp + +plugins: + - search + - i18n: + docs_structure: suffix + languages: + - locale: en + default: true + name: English + build: true + - locale: es + name: Español + build: true + site_description: Cliente HTTP para Go con patrones de resiliencia integrados. Cero dependencias. + nav_translations: + Home: Inicio + Getting started: Primeros pasos + Installation: Instalación + Quickstart: Inicio rápido + Resiliency: Resiliencia + Retry & backoff: Reintentos y backoff + Circuit breaker: Circuit breaker + Rate limiting: Rate limiting + Timeouts: Timeouts + Observability: Observabilidad + Logging: Logging + Metrics: Métricas + Invalid configuration: Configuración inválida + Reference: Referencia + Architecture: Arquitectura + Benchmarks: Benchmarks + API reference: Referencia de la API + +markdown_extensions: + - admonition + - attr_list + - md_in_html + - tables + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + +extra_css: + - stylesheets/extra.css diff --git a/web/requirements.txt b/web/requirements.txt new file mode 100644 index 0000000..c395631 --- /dev/null +++ b/web/requirements.txt @@ -0,0 +1,12 @@ +# Documentation site toolchain. Versions are pinned exactly, not floored. +# +# MkDocs 2.0 removes the plugin system and rewrites theming, with no migration +# path; mkdocs-material will not follow it. Both plugins below depend on the +# system 2.0 drops, so an unpinned install would break this build the day 2.0 +# lands. Bump these deliberately, never automatically. +# +# None of this reaches the Go module: rhttp itself stays dependency-free. + +mkdocs==1.6.1 +mkdocs-material==9.7.7 +mkdocs-static-i18n==1.3.1