Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ Thumbs.db

# Debug
__debug_bin*

# Documentation site build output
/web/site/
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
100 changes: 97 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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)

Expand All @@ -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

Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions benchmark_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading
Loading