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 @@
+
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 @@
+
+
+# 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.
+
+
+
+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 @@
+
+
+# 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.
+
+
+
+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