Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
d4631eb
Add initial project setup with README, Makefile, and configuration files
Jan 19, 2026
14b91dd
Add CI configuration for testing, linting, building, and benchmarking
Jan 19, 2026
f5ec9f0
Merge pull request #1 from oswaldom-code/feat/project-setup
oswaldom-code Jan 19, 2026
8db96c7
Fix: CI cache dependency paths and correct golangci configuration typo
Jul 1, 2026
4636c33
chore: Update CI to remove go.sum from go mod tidy verification
Jul 1, 2026
3d12700
chore: Disable CI workflow by commenting out configuration
Jul 1, 2026
cc78999
Merge pull request #2 from oswaldom-code/feat/project-setup
oswaldom-code Jul 1, 2026
116fb33
feat(httpclient): add Phase 1 foundation package
Jul 1, 2026
d6c46a5
chore: Downgrade Go version to 1.21 and update roadmap in README
Jul 1, 2026
11d7dae
fix: keep response body readable in timeout and retry middleware
Jul 21, 2026
078c2f1
refactor: extract retry helpers to drop gocognit nolint
Jul 21, 2026
292cdb2
fix: enforce single-probe half-open in circuit breaker
Jul 21, 2026
ae44536
chore: use math/rand v1 to keep the go 1.21 floor
Jul 21, 2026
34930ae
refactor!: rename module to github.com/oswaldom-code/rhttp
Jul 21, 2026
2cce947
ci: reactivate the CI workflow
Jul 21, 2026
69a75bd
fix: clone request per attempt in retry to honor RoundTripper contract
Jul 22, 2026
3f3cbc7
fix: give each CircuitBreaker application its own breaker instance
Jul 22, 2026
7e8811e
refactor: classify errors structurally, drop string matching
Jul 22, 2026
9211ea1
fix: base DefaultIsRetryable on error classification
Jul 22, 2026
891047c
fix: exclude client cancellations from circuit breaker failures
Jul 22, 2026
567339a
fix: bound the retry body drain to 256 KB
Jul 22, 2026
4d4a129
fix: buffer opaque reader bodies so retries can rewind them
Jul 23, 2026
7e5fedb
refactor: remove dead object-pool API
Jul 23, 2026
1fc19c4
feat: add PathNormalizer to bound metrics path cardinality
Jul 24, 2026
86c05f0
docs: replace misleading net/http speed claim with honest overhead nu…
Jul 24, 2026
3eb4ee1
docs: replace static coverage claim with Codecov badge reference
Jul 24, 2026
c1f9c88
docs: document middleware ordering semantics
Jul 24, 2026
f5f1f68
docs: recommend Retry outside CircuitBreaker per diagnostic 4.4
Jul 24, 2026
bb075d1
docs: add godoc to new exported API and document SetBody buffering
Jul 24, 2026
ed37d80
docs: translate README Motivation section to English
Jul 24, 2026
b02564a
docs: add CHANGELOG for the initial v0.1.0 release
Jul 24, 2026
59c212b
feat: add standalone comparison benchmark suite with report generator
Jul 24, 2026
c80e8bd
fix: keep builder SetTimeout response body readable via cancelBody
Jul 25, 2026
7593d22
fix: make Timeout(d<=0) a no-op per config-invalid convention
Jul 25, 2026
743afc8
fix: make NewTokenBucket with invalid config an unlimited no-op
Jul 25, 2026
f6aa1b4
fix: evaluate IsFailure outside the circuit breaker mutex
Jul 25, 2026
5357839
fix: gate circuit breaker results by admission generation
Jul 25, 2026
d89d2a1
refactor: export Client struct and drop the interface plus free R()
Jul 25, 2026
f4c0766
refactor: drop Wait from the RateLimiter interface
Jul 25, 2026
0c8abf0
refactor: export RoundTripperFunc and retire the internal package
Jul 25, 2026
5df8a27
chore: adapt benchmarks module to the new Client API and refresh report
Jul 25, 2026
77990d9
refactor: remove PerHostRateLimiter ahead of v1
Jul 25, 2026
a44229b
feat: pass the previous response to backoff and honor Retry-After
Jul 25, 2026
2c04986
feat: add opinionated DecodeJSON and CircuitState.String
Jul 25, 2026
0e40681
fix: close the request body when middleware short-circuits
Jul 25, 2026
273b35f
fix: saturate exponential backoff at maxDuration on int64 overflow
Jul 25, 2026
a5ccf5e
fix: default nil context in Do and stop backoff wait timers
Jul 25, 2026
4d2374a
fix: make byte-backed builder bodies survive re-execution
Jul 25, 2026
70dfb06
perf: skip the retry clone on the first attempt
Jul 25, 2026
2065d65
perf: precompute token wait time and lazy-init builder param maps
Jul 25, 2026
11a266d
test: strengthen four weak assertions
Jul 25, 2026
b08db05
test: pin the middleware ordering semantics with interaction tests
Jul 25, 2026
ef24620
test: cover the streaming path for bodies over the buffer limit
Jul 25, 2026
5970c55
test: cover State and Tokens, widen breaker sleep margins
Jul 25, 2026
510530e
test: close the remaining coverage gaps
Jul 25, 2026
1f3c7d0
build: repair coverage-summary and the ci make target
Jul 25, 2026
954d41d
docs: publish the final comparative benchmark tables
Jul 25, 2026
cf1d28b
docs: cut the 0.1.0 changelog entry
Jul 25, 2026
554f371
merge: reconcile the squash-merged main history
Jul 25, 2026
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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Changelog

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).

## [0.1.0] - 2026-07-25

First public release.

### Added

- Middleware-based HTTP client (`New` returning `*Client`, `WithMiddleware`, `WithTransport`) built on `http.RoundTripper`, plus the exported `RoundTripperFunc` adapter that makes a custom middleware a one-liner.
- Resiliency middleware: `Timeout`, `Retry` with pluggable backoff, `CircuitBreaker`, and `RateLimit` (token bucket behind the `RateLimiter` interface: `TryAcquire` plus `WaitContext`).
- `SharedCircuitBreaker` (`NewCircuitBreaker`) for circuit state shared across multiple clients, with observable `State()` and `CircuitState.String()`.
- Observability middleware: `Logging` and `Metrics`. `MetricsConfig.PathNormalizer` bounds metrics label cardinality (raw path is omitted by default).
- Backoff strategies: constant, linear, exponential, Fibonacci, and their jitter variants — all zero-alloc and overflow-safe. `BackoffFunc` receives the response that triggered the retry, and the `WithRetryAfter` decorator honors the `Retry-After` header (delay-seconds or HTTP-date) on 429/503.
- Fluent request builder (`Client.R`) with JSON, XML, form and reader bodies, path parameters, and query parameters. Reader bodies up to 10 MB are buffered so retries can rewind them; larger bodies stream and are sent exactly once.
- `DecodeJSON` response helper: always drains and closes the body, fails on status >= 300.
- Error classification: `Classify`, `IsRetryable`, `IsTimeout`, `IsConnection`, and related helpers.
- Zero external dependencies; Go standard library only.

[0.1.0]: https://github.com/oswaldom-code/rhttp/releases/tag/v0.1.0
13 changes: 7 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ Production-grade HTTP client for Go with built-in resiliency patterns. Zero exte

```
rhttp/ # Package rhttp lives at the module root
├── client.go # Client interface and New() constructor
├── middleware.go # Middleware type and chain() function
├── client.go # Client struct and New() constructor
├── middleware.go # Middleware and RoundTripperFunc types, chain()
├── transport.go # Optimized DefaultTransport()
├── options.go # Functional options pattern
├── errors.go # Sentinel errors
Expand All @@ -22,8 +22,6 @@ rhttp/ # Package rhttp lives at the module root
├── metrics.go # Metrics middleware
├── request.go # Fluent API (RequestBuilder)
├── pool.go # Object pooling with sync.Pool
├── internal/ # Internal package
│ └── roundtripper.go
├── examples/ # Runnable examples
├── .github/workflows/ # CI with GitHub Actions
├── Makefile # Development commands
Expand Down Expand Up @@ -66,7 +64,7 @@ func MyMiddleware(cfg Config) Middleware {
### Tests
- Use standard `testing` package (project does NOT use ginkgo/gomega by design - zero deps)
- Name files `*_test.go`
- Use `internal.MockRoundTripper` for transport mocks
- Use `rhttp.RoundTripperFunc` for transport mocks
- Respect context in mocks with `select { case <-req.Context().Done(): ... }`

### Errors
Expand All @@ -90,9 +88,12 @@ func MyMiddleware(cfg Config) Middleware {
## Recommended Middleware Order

```go
Logging → Metrics → Timeout → RateLimit → CircuitBreakerRetry
Logging → Metrics → Timeout → RateLimit → RetryCircuitBreaker
```

Retry sits outside CircuitBreaker so every attempt consults the circuit: a
tripped breaker short-circuits the remaining attempts.

## Pre-Commit Checklist

1. `make fmt` - Code formatted
Expand Down
10 changes: 7 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help test test-race test-coverage coverage-summary bench lint fmt vet docs check clean install-tools
.PHONY: help test test-race test-short test-coverage coverage-summary bench bench-compare lint fmt fmt-check vet docs check check-all clean install-tools deps ci version info

.DEFAULT_GOAL := help

Expand Down Expand Up @@ -49,7 +49,7 @@ test-coverage:
@echo ""
@echo "$(GREEN)Coverage report generated: $(COVERAGE_HTML)$(NC)"

coverage-summary:
coverage-summary: test-coverage
@echo "$(GREEN)=== Total Coverage ===$(NC)"
@$(GOCMD) tool cover -func=$(COVERAGE_FILE) | tail -1
@echo ""
Expand All @@ -62,7 +62,7 @@ test-short:

bench:
@echo "$(GREEN)Running benchmarks...$(NC)"
$(GOTEST) -bench=. -benchmem $(PACKAGES)
$(GOTEST) -bench=. -benchmem -count=5 $(PACKAGES)

bench-compare:
@echo "$(GREEN)Running benchmarks for comparison...$(NC)"
Expand Down Expand Up @@ -122,6 +122,10 @@ install-tools:
go install golang.org/x/pkgsite/cmd/pkgsite@latest
@echo "$(GREEN)Done! Make sure $(GOPATH)/bin is in your PATH.$(NC)"

deps:
@echo "$(GREEN)Downloading dependencies...$(NC)"
$(GOMOD) download

ci: deps fmt-check vet lint test-race
@echo "$(GREEN)CI pipeline passed!$(NC)"

Expand Down
168 changes: 113 additions & 55 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,37 +11,36 @@ Production-grade HTTP client for Go with built-in resiliency patterns.

## Motivation

Después de implementar clientes HTTP con patrones de resiliencia en múltiples proyectos
de microservicios, identificé un patrón recurrente:
After building HTTP clients with resiliency patterns across multiple microservice
projects, a recurring pattern emerged:

1. **La stdlib no es suficiente** - `net/http` es potente pero no incluye retry,
circuit breaker ni rate limiting
2. **Las dependencias son un problema** - Librerías como Resty traen dependencias
transitivas que complican auditorías de seguridad y aumentan el tamaño del binario
3. **Reinventar la rueda es costoso** - Cada equipo termina escribiendo su propio
wrapper con bugs sutiles en manejo de contextos, timeouts y connection pooling
1. **The stdlib is not enough** - `net/http` is powerful but ships no retry,
circuit breaker, or rate limiting
2. **Dependencies are a liability** - Libraries like Resty pull in transitive
dependencies that complicate security audits and grow the binary size
3. **Reinventing the wheel is costly** - Every team ends up writing its own
wrapper with subtle bugs in context handling, timeouts, and connection pooling

Esta librería resuelve ese problema: **resiliencia production-ready con cero dependencias**.
This library solves that: **production-ready resiliency with zero dependencies**.

### Usage Modes

| Modo | Cuándo usarlo |
|------|---------------|
| `go get` | Proyectos que aceptan dependencias externas |
| Copiar a `pkg/rhttp` | Políticas estrictas de zero-deps, vendor everything |
| Mode | When to use it |
|------|----------------|
| `go get` | Projects that accept external dependencies |
| Copy into `pkg/rhttp` | Strict zero-deps policies, vendor everything |

El código está diseñado para funcionar en ambos escenarios sin modificaciones.
The code is designed to work in both scenarios without modification.

## Features

- **Zero dependencies** - Only Go standard library
- **Faster than net/http** - 35% faster than `http.Client` baseline
- **Low overhead** - The full middleware stack adds ~1 μs per request
- **Middleware architecture** - Composable, testable, extensible
- **Fluent API** - Resty-style request builder
- **Resiliency patterns** - Retry, circuit breaker, rate limiting, timeout
- **Multiple backoff strategies** - Constant, linear, exponential, Fibonacci, jitter variants
- **Object pooling** - Reduced allocations via `sync.Pool`
- **100% test coverage** - 101 tests
- **Well tested** - Race-clean suite; live coverage in the Codecov badge above

## Installation

Expand Down Expand Up @@ -98,14 +97,14 @@ func main() {
client := rhttp.New()

// GET request with query params
resp, err := rhttp.R(client).
resp, err := client.R().
SetHeader("Authorization", "Bearer token").
SetQueryParam("page", "1").
SetQueryParam("limit", "10").
Get("https://api.example.com/users")

// POST request with JSON body
resp, err := rhttp.R(client).
resp, err := client.R().
SetAuthToken("my-token").
SetBodyJSON(map[string]string{
"name": "John",
Expand All @@ -114,7 +113,7 @@ resp, err := rhttp.R(client).
Post("https://api.example.com/users")

// Path parameters
resp, err := rhttp.R(client).
resp, err := client.R().
SetPathParam("org", "acme").
SetPathParam("repo", "api").
Get("https://api.github.com/repos/{org}/{repo}")
Expand Down Expand Up @@ -161,7 +160,8 @@ client := rhttp.New(
| `ExponentialBackoffEqualJitter(base, max)` | `base * 2^attempt / 2 + random(0, half)` |
| `DecorrelatedJitterBackoff(base, max)` | AWS-style decorrelated jitter |

Composable with `WithJitter()`, `WithMin()`, `WithMax()`.
Composable with `WithJitter()`, `WithMin()`, `WithMax()`, and `WithRetryAfter()`
(honors the `Retry-After` header on 429/503 responses).

### Circuit Breaker

Expand Down Expand Up @@ -196,9 +196,6 @@ client := rhttp.New(
}),
),
)

// Per-host rate limiting
perHostLimiter := rhttp.NewPerHostRateLimiter(50, 5) // 50 req/s per host
```

### Logging
Expand Down Expand Up @@ -236,6 +233,22 @@ client := rhttp.New(

`MetricEvent` fields: `Method`, `Host`, `Path`, `StatusCode`, `Duration`, `BytesSent`, `BytesReceived`, `Error`, `Success`

#### Path cardinality

Exporting a raw request path (`/users/8f3a.../orders/2941`) as a metrics label creates one time series per ID, which grows Prometheus memory without bound. To prevent this, `Path` is **empty by default** and is only populated when you provide a `PathNormalizer` that collapses high-cardinality segments to a template:

```go
rhttp.Metrics(rhttp.MetricsConfig{
Recorder: recorder,
PathNormalizer: func(p string) string {
// /users/8f3a/orders/2941 -> /users/:id/orders/:id
return idSegment.ReplaceAllString(p, "/:id")
},
})
```

To emit the raw path anyway (not recommended as a metrics label), use `func(p string) string { return p }`.

## Error Classification

```go
Expand Down Expand Up @@ -267,7 +280,7 @@ if err != nil {

## Middleware Order

Middleware executes in the order specified:
The **first middleware in the list is the outermost**: it runs first on the way in and last on the way out. Each subsequent middleware wraps the ones after it, and the transport sits at the center.

```go
client := rhttp.New(
Expand All @@ -276,13 +289,31 @@ client := rhttp.New(
rhttp.Metrics(...), // 2. Start timing
rhttp.Timeout(...), // 3. Apply timeout
rhttp.RateLimit(...), // 4. Check rate limit
rhttp.CircuitBreaker(...), // 5. Check circuit
rhttp.Retry(...), // 6. Retry on failure
rhttp.Retry(...), // 5. Retry on failure
rhttp.CircuitBreaker(...), // 6. Check circuit per attempt
),
)
```

Recommended order: `Logging → Metrics → Timeout → RateLimit → CircuitBreaker → Retry`
Recommended order: `Logging → Metrics → Timeout → RateLimit → Retry → CircuitBreaker`

### Timeout placement changes its meaning

Where you put `Timeout` relative to `Retry` selects one of two semantics — both valid, but very different:

| Pattern | Order | Meaning |
|---------|-------|---------|
| **Total budget** | `Timeout → Retry` | The timeout covers **all attempts and their backoffs combined**. Once it expires, no further retries happen. |
| **Per-attempt timeout** | `Retry → Timeout` | Each attempt gets its **own fresh timeout**; the total wall-clock time is roughly `attempts × timeout` plus backoffs. |

See the runnable `ExampleRetry_totalBudget` and `ExampleRetry_perAttemptTimeout` for both wirings.

### Retry vs CircuitBreaker

| Order | Effect |
|-------|--------|
| `Retry → CircuitBreaker` (retry outer) **— recommended** | Each attempt consults the circuit; a tripped breaker short-circuits the remaining attempts. The circuit counts every attempt. |
| `CircuitBreaker → Retry` (breaker outer) | The circuit sees one fully-retried request as a single call; retries are not individually gated by the breaker. |

## Custom Transport

Expand All @@ -300,39 +331,67 @@ client := rhttp.New(
transport := rhttp.DefaultTransport() // HTTP/2 enabled, optimized pool
```

## Object Pooling
## Benchmarks

Reduce allocations with buffer pooling:
Two suites, measured 2026-07-25 on linux/amd64 (Intel Core i7-1255U, Go 1.24):
the in-repo microbenchmarks (`make bench`) measure client and middleware
overhead against a no-op transport, and a standalone comparison harness
([`benchmarks/`](benchmarks/), `make report`) measures rhttp against Resty
v2.17.2, go-retryablehttp v0.7.8 and Heimdall v7.0.3 with equivalent
configuration (5s timeout, 3 attempts, exponential backoff 100ms-2s).

```go
// Get a buffer from the pool
buf := rhttp.GetBuffer()
defer rhttp.PutBuffer(buf)
### Middleware overhead (no network)

buf.WriteString("request body")
```

## Benchmarks
Minimum of 5 runs:

```
goos: linux
goarch: amd64
cpu: Intel Core i7-1255U

BenchmarkClient_Baseline-12 235 ns/op 656 B/op 4 allocs/op
BenchmarkStdHttpClient_Baseline-12 317 ns/op 600 B/op 7 allocs/op (+35%)
BenchmarkClient_WithRetry-12 265 ns/op 656 B/op 4 allocs/op
BenchmarkClient_WithCircuitBreaker-12 271 ns/op 656 B/op 4 allocs/op
BenchmarkClient_AllMiddleware-12 1143 ns/op 1472 B/op 12 allocs/op
BenchmarkTokenBucket_TryAcquire-12 52 ns/op 0 B/op 0 allocs/op
BenchmarkBackoff_Exponential-12 7 ns/op 0 B/op 0 allocs/op
BenchmarkMiddlewareOverhead_Baseline-12 240 ns/op 656 B/op 4 allocs/op
BenchmarkMiddlewareOverhead_WithRetry-12 272 ns/op 656 B/op 4 allocs/op
BenchmarkMiddlewareOverhead_WithCircuitBreaker-12 266 ns/op 656 B/op 4 allocs/op
BenchmarkMiddlewareOverhead_AllMiddleware-12 1030 ns/op 1589 B/op 13 allocs/op
BenchmarkStdHttpClient_Baseline-12 256 ns/op 552 B/op 5 allocs/op
BenchmarkTokenBucket_TryAcquire-12 48 ns/op 0 B/op 0 allocs/op
BenchmarkBackoffStrategies/Exponential-12 8 ns/op 0 B/op 0 allocs/op
```

**Key results:**
- 35% faster than `net/http` client baseline
- All middleware stack: ~1μs overhead (negligible vs network latency)
- Rate limiter: 52ns per check, zero allocations
- Backoff strategies: <10ns, zero allocations
- Full middleware stack: ~1 μs and ~1.5 KB per request — negligible against network latency (0.5–500 ms)
- Rate limiter: 48 ns per check, zero allocations
- Backoff strategies: <10 ns, zero allocations

### Comparison with other clients

Wrapper overhead (no-op transport, timeout + 3-attempt retry configured everywhere, min of 5 runs):

| Client | ns/op | allocs/op | vs best |
|---|---:|---:|---:|
| rhttp (Timeout+Retry) | 910 | 12 | 1.00x |
| rhttp (Timeout+Retry+CircuitBreaker) | 946 | 12 | 1.04x |
| net/http (Timeout only, no retry) | 1750 | 26 | 1.92x |
| go-retryablehttp | 1775 | 26 | 1.95x |
| Heimdall (retry) | 2555 | 32 | 2.81x |
| Resty (retry) | 5818 | 48 | 6.39x |

End-to-end (~1 KB JSON over loopback):

| Client | ns/op | allocs/op | vs best |
|---|---:|---:|---:|
| go-retryablehttp | 58309 | 74 | 1.00x |
| net/http (Timeout only, no retry) | 60995 | 75 | 1.05x |
| Heimdall (retry) | 61009 | 80 | 1.05x |
| rhttp (Timeout+Retry) | 61923 | 76 | 1.06x |
| rhttp (Timeout+Retry+CircuitBreaker) | 62817 | 76 | 1.08x |
| Resty (retry) | 71696 | 96 | 1.23x |

**Read the caveats before quoting these numbers:**

- All clients are configured equivalently and fully consume and close each response body.
- `net/http` does not retry: it is the floor, not a symmetric competitor.
- Heimdall runs without its Hystrix circuit breaker (retry only, for feature symmetry).
- Resty buffers the full response body by design.
- Loopback amplifies relative overhead: against a real network (0.5-500 ms per
request) every client in the table performs the same for practical purposes.

Full methodology and reproduction steps: [`benchmarks/REPORT.md`](benchmarks/REPORT.md).

## Design Principles

Expand Down Expand Up @@ -413,7 +472,6 @@ All PRs must pass CI checks before merging.
- [x] **Metrics middleware** - Pluggable `MetricsRecorder` interface
- [x] **Error classification** - Timeout, connection, DNS, TLS, temporary
- [x] **Fluent API** - Resty-style `RequestBuilder`
- [x] **Object pooling** - Reduced allocations via `sync.Pool`
- [x] **Zero dependencies** - Only Go standard library

### Phase 2: Advanced Resiliency
Expand Down
Loading
Loading