diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..57312e9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,104 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + name: Test (Go ${{ matrix.go-version }}) + runs-on: ubuntu-latest + strategy: + matrix: + go-version: ['1.21', '1.22', '1.23'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + cache: true + cache-dependency-path: go.mod + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + if: matrix.go-version == '1.23' + uses: codecov/codecov-action@v4 + with: + files: coverage.out + fail_ci_if_error: false + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + cache: true + cache-dependency-path: go.mod + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + args: --timeout=5m + + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + cache: true + cache-dependency-path: go.mod + + - name: Build + run: go build ./... + + - name: Verify go.mod is tidy + run: | + go mod tidy + git diff --exit-code go.mod + + benchmark: + name: Benchmark + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + cache: true + cache-dependency-path: go.mod + + - name: Run benchmarks + run: go test -bench=. -benchmem ./... | tee benchmark.txt + + - name: Store benchmark result + uses: actions/upload-artifact@v4 + with: + name: benchmark-results + path: benchmark.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c791869 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Binaries +*.test +*.exe +/examples/*/main +/basic + +# Coverage +coverage/ +*.out + +# Benchmarks +benchmarks.txt +benchmark_results.txt + +# Vendor +/vendor/ + +# IDE +/.idea/ +/.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Debug +__debug_bin* diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..d09043a --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,151 @@ +run: + timeout: 5m + modules-download-mode: readonly + +linters: + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + - bodyclose # Check HTTP response body is closed + - dupl # Code duplication + - gocognit # Cognitive complexity + - goconst # Repeated strings that could be constants + - gocritic # Opinionated linter + - gocyclo # Cyclomatic complexity + - gofmt # Formatting + - goimports # Import formatting + - gosec # Security issues + - misspell # Spelling mistakes + - nakedret # Naked returns in functions + - prealloc # Slice preallocation + - revive # Fast, configurable linter + - unconvert # Unnecessary type conversions + - unparam # Unused function parameters + - whitespace # Whitespace issues + +linters-settings: + errcheck: + check-type-assertions: true + check-blank: true + exclude-functions: + - io.Copy + - io.ReadAll + - (io.Closer).Close + + gocognit: + min-complexity: 20 + + gocyclo: + min-complexity: 15 + + goconst: + min-len: 3 + min-occurrences: 3 + + gocritic: + enabled-tags: + - diagnostic + - performance + - style + disabled-checks: + - hugeParam + - whyNoLint + - commentedOutCode + + gofmt: + simplify: true + + goimports: + local-prefixes: github.com/oswaldom-code/rhttp + + gosec: + excludes: + - G104 # Audit errors not checked (we handle this with errcheck) + - G304 # File path provided as taint input (not applicable for HTTP client) + + misspell: + locale: US + + nakedret: + max-func-lines: 30 + + prealloc: + simple: true + range-loops: true + for-loops: false + + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: if-return + - name: increment-decrement + - name: var-naming + - name: var-declaration + - name: package-comments + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unused-parameter + - name: unreachable-code + - name: redefines-builtin-id + + unparam: + check-exported: false + +issues: + exclude-rules: + - path: _test\.go + linters: + - dupl + - gocognit + - gocyclo + - gosec + - unparam + - errcheck # Test code commonly ignores errors + - bodyclose # Test roundtrippers often return mock responses + - goconst # Test strings don't need to be constants + + # Exclude revive unused-parameter in test files + - path: _test\.go + text: "unused-parameter" + linters: + - revive + + # Allow complexity in internal roundtripper + - path: internal/ + linters: + - gocognit + - gocyclo + + # Exclude unused functions that are part of the pool API + - path: pool\.go + text: "func `acquireResponse` is unused" + linters: + - unused + + max-issues-per-linter: 50 + max-same-issues: 10 + new: false + +output: + formats: + - format: colored-line-number + print-issued-lines: true + print-linter-name: true + sort-results: true diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..281ed60 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,117 @@ +# CLAUDE.md - Project Instructions + +## Description +Production-grade HTTP client for Go with built-in resiliency patterns. Zero external dependencies. + +## Project Structure + +``` +rhttp/ # Package rhttp lives at the module root +├── client.go # Client interface and New() constructor +├── middleware.go # Middleware type and chain() function +├── transport.go # Optimized DefaultTransport() +├── options.go # Functional options pattern +├── errors.go # Sentinel errors +├── errorclass.go # Error classification +├── timeout.go # Timeout middleware +├── retry.go # Retry middleware +├── backoff.go # Backoff strategies (7 variants) +├── circuitbreaker.go # Thread-safe circuit breaker +├── ratelimit.go # Token bucket rate limiter +├── logging.go # Logging middleware +├── 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 +└── .golangci.yml # Linter configuration +``` + +## Development Commands + +```bash +make test # Run tests +make test-race # Run tests with race detector +make test-coverage # Generate coverage report +make coverage-summary # Show coverage summary +make bench # Run benchmarks +make lint # Run golangci-lint +make check # Run all checks +make fmt # Format code +``` + +## Code Conventions + +### Middleware +- Implement as `func(http.RoundTripper) http.RoundTripper` +- Use struct implementing `RoundTrip(req *http.Request) (*http.Response, error)` +- If config is nil or invalid, return next unchanged + +```go +func MyMiddleware(cfg Config) Middleware { + if cfg.Invalid() { + return func(next http.RoundTripper) http.RoundTripper { + return next + } + } + return func(next http.RoundTripper) http.RoundTripper { + return myRoundTripper{next: next, cfg: cfg} + } +} +``` + +### 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 +- Respect context in mocks with `select { case <-req.Context().Done(): ... }` + +### Errors +- Sentinel errors in `errors.go`: `var ErrXxx = errors.New("rhttp: description")` +- Error classification in `errorclass.go` + +### Backoff +- Functions returning `BackoffFunc = func(attempt int) time.Duration` +- Zero allocations (verify with benchmarks) +- Respect max duration + +## Design Patterns Used + +1. **Middleware Chain** - Chained RoundTrippers +2. **Functional Options** - Configuration with `WithXxx()` +3. **Circuit Breaker** - State machine (Closed/Open/Half-Open) +4. **Token Bucket** - Rate limiting +5. **Object Pool** - sync.Pool for buffers +6. **Builder Pattern** - Fluent API in RequestBuilder + +## Recommended Middleware Order + +```go +Logging → Metrics → Timeout → RateLimit → CircuitBreaker → Retry +``` + +## Pre-Commit Checklist + +1. `make fmt` - Code formatted +2. `make lint` - No linter errors +3. `make test-race` - Tests pass with race detector +4. `go mod tidy` - go.mod is clean + +## Performance + +- Client must be faster than standard `net/http` +- Rate limiter: ~50ns per operation, zero allocs +- Backoff strategies: <10ns, zero allocs +- Run `make bench` to check for regressions + +## Pending Roadmap + +See "Roadmap" section in README.md for pending features: +- Circuit breaker per endpoint +- OpenTelemetry integration +- OAuth2 support +- Load balancing +- Response caching diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..75881fc --- /dev/null +++ b/Makefile @@ -0,0 +1,136 @@ +.PHONY: help test test-race test-coverage coverage-summary bench lint fmt vet docs check clean install-tools + +.DEFAULT_GOAL := help + +# Go parameters +GOCMD=go +GOTEST=$(GOCMD) test +GOBUILD=$(GOCMD) build +GOFMT=$(GOCMD) fmt +GOVET=$(GOCMD) vet +GOMOD=$(GOCMD) mod + +# Coverage +COVERAGE_DIR=coverage +COVERAGE_FILE=$(COVERAGE_DIR)/coverage.out +COVERAGE_HTML=$(COVERAGE_DIR)/coverage.html + +# Packages +PACKAGES=./... + +# Colors for terminal output +GREEN=\033[0;32m +YELLOW=\033[0;33m +RED=\033[0;31m +NC=\033[0m # No Color + +help: + @echo "rhttp - Production-grade HTTP client for Go" + @echo "" + @echo "Usage: make [target]" + @echo "" + @echo "Targets:" + @sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':' | sed -e 's/^/ /' + +test: + @echo "$(GREEN)Running tests...$(NC)" + $(GOTEST) -v $(PACKAGES) + +test-race: + @echo "$(GREEN)Running tests with race detector...$(NC)" + $(GOTEST) -v -race $(PACKAGES) + +test-coverage: + @echo "$(GREEN)Running tests with coverage...$(NC)" + @mkdir -p $(COVERAGE_DIR) + $(GOTEST) -v -coverprofile=$(COVERAGE_FILE) -covermode=atomic $(PACKAGES) + $(GOCMD) tool cover -html=$(COVERAGE_FILE) -o $(COVERAGE_HTML) + $(GOCMD) tool cover -func=$(COVERAGE_FILE) + @echo "" + @echo "$(GREEN)Coverage report generated: $(COVERAGE_HTML)$(NC)" + +coverage-summary: + @echo "$(GREEN)=== Total Coverage ===$(NC)" + @$(GOCMD) tool cover -func=$(COVERAGE_FILE) | tail -1 + @echo "" + @echo "$(YELLOW)=== Uncovered Functions (0.0%) ===$(NC)" + @$(GOCMD) tool cover -func=$(COVERAGE_FILE) | awk '$$NF == "0.0%"' + +test-short: + @echo "$(GREEN)Running short tests...$(NC)" + $(GOTEST) -v -short $(PACKAGES) + +bench: + @echo "$(GREEN)Running benchmarks...$(NC)" + $(GOTEST) -bench=. -benchmem $(PACKAGES) + +bench-compare: + @echo "$(GREEN)Running benchmarks for comparison...$(NC)" + $(GOTEST) -bench=. -benchmem -count=5 $(PACKAGES) | tee benchmarks.txt + +lint: + @echo "$(GREEN)Running linter...$(NC)" + @if command -v golangci-lint >/dev/null 2>&1; then \ + golangci-lint run $(PACKAGES); \ + else \ + echo "$(YELLOW)golangci-lint not installed. Run 'make install-tools' first.$(NC)"; \ + exit 1; \ + fi + +fmt: + @echo "$(GREEN)Formatting code...$(NC)" + $(GOFMT) $(PACKAGES) + +fmt-check: + @echo "$(GREEN)Checking code formatting...$(NC)" + @test -z "$$(gofmt -l .)" || (echo "$(RED)Code is not formatted. Run 'make fmt'$(NC)" && gofmt -l . && exit 1) + +vet: + @echo "$(GREEN)Running go vet...$(NC)" + $(GOVET) $(PACKAGES) + +docs: + @echo "$(GREEN)Starting documentation server...$(NC)" + @echo "Open http://localhost:8080/github.com/oswaldom-code/rhttp" + @if command -v pkgsite >/dev/null 2>&1; then \ + pkgsite -http=:8080; \ + else \ + echo "$(YELLOW)pkgsite not installed. Run 'make install-tools' first.$(NC)"; \ + echo "Falling back to godoc..."; \ + godoc -http=:8080; \ + fi + +check: fmt-check vet lint test + @echo "$(GREEN)All checks passed!$(NC)" + +check-all: fmt-check vet lint test-race + @echo "$(GREEN)All checks passed!$(NC)" + +clean: + @echo "$(GREEN)Cleaning...$(NC)" + @rm -rf $(COVERAGE_DIR) + @rm -f benchmarks.txt + @rm -f benchmark_results.txt + $(GOCMD) clean -cache -testcache + + +install-tools: + @echo "$(GREEN)Installing development tools...$(NC)" + @echo "Installing golangci-lint..." + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + @echo "Installing pkgsite..." + go install golang.org/x/pkgsite/cmd/pkgsite@latest + @echo "$(GREEN)Done! Make sure $(GOPATH)/bin is in your PATH.$(NC)" + +ci: deps fmt-check vet lint test-race + @echo "$(GREEN)CI pipeline passed!$(NC)" + +version: + @$(GOCMD) version + +info: + @echo "Module: $$(head -1 go.mod | cut -d' ' -f2)" + @echo "Go version: $$($(GOCMD) version | cut -d' ' -f3)" + @echo "Packages: $$($(GOCMD) list $(PACKAGES) | wc -l | tr -d ' ')" + @echo "Test files: $$(find . -name '*_test.go' | wc -l | tr -d ' ')" + @echo "Source files: $$(find . -name '*.go' ! -name '*_test.go' | wc -l | tr -d ' ')" diff --git a/README.md b/README.md index c3dfe54..d76d006 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,467 @@ -# go-httpclient -HTTP client for Go. +# rhttp + +Production-grade HTTP client for Go with built-in resiliency patterns. + +[![CI](https://github.com/oswaldom-code/rhttp/actions/workflows/ci.yml/badge.svg)](https://github.com/oswaldom-code/rhttp/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/oswaldom-code/rhttp/branch/main/graph/badge.svg)](https://codecov.io/gh/oswaldom-code/rhttp) +[![Go Report Card](https://goreportcard.com/badge/github.com/oswaldom-code/rhttp)](https://goreportcard.com/report/github.com/oswaldom-code/rhttp) +[![Go Reference](https://pkg.go.dev/badge/github.com/oswaldom-code/rhttp.svg)](https://pkg.go.dev/github.com/oswaldom-code/rhttp) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?logo=go)](https://go.dev/) + +## Motivation + +Después de implementar clientes HTTP con patrones de resiliencia en múltiples proyectos +de microservicios, identificé un patrón recurrente: + +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 + +Esta librería resuelve ese problema: **resiliencia production-ready con cero dependencias**. + +### 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 | + +El código está diseñado para funcionar en ambos escenarios sin modificaciones. + +## Features + +- **Zero dependencies** - Only Go standard library +- **Faster than net/http** - 35% faster than `http.Client` baseline +- **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 + +## Installation + +```bash +go get github.com/oswaldom-code/rhttp +``` + +Requires Go 1.21+ + +## Quick Start + +### Basic Usage + +```go +package main + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/oswaldom-code/rhttp" +) + +func main() { + // Create client with middleware + 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, + }), + ), + ) + + // Make request + req, _ := http.NewRequest("GET", "https://api.example.com/users", nil) + resp, err := client.Do(context.Background(), req) + if err != nil { + panic(err) + } + defer resp.Body.Close() + + fmt.Println("Status:", resp.StatusCode) +} +``` + +### Fluent API + +```go +client := rhttp.New() + +// GET request with query params +resp, err := rhttp.R(client). + 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). + SetAuthToken("my-token"). + SetBodyJSON(map[string]string{ + "name": "John", + "email": "john@example.com", + }). + Post("https://api.example.com/users") + +// Path parameters +resp, err := rhttp.R(client). + SetPathParam("org", "acme"). + SetPathParam("repo", "api"). + Get("https://api.github.com/repos/{org}/{repo}") +``` + +## Middleware + +### Timeout + +```go +client := rhttp.New( + rhttp.WithMiddleware( + rhttp.Timeout(5*time.Second), + ), +) +``` + +Respects existing context deadlines - uses the shorter of the two. + +### Retry + +```go +client := rhttp.New( + rhttp.WithMiddleware( + rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + Backoff: rhttp.ExponentialBackoff(100*time.Millisecond, 10*time.Second), + IsRetryable: rhttp.DefaultIsRetryable, // 429, 502, 503, 504 + RetryAllMethods: false, // Only retry idempotent methods by default + }), + ), +) +``` + +**Built-in backoff strategies:** + +| Strategy | Description | +|----------|-------------| +| `ConstantBackoff(d)` | Always wait `d` | +| `LinearBackoff(base, max)` | `base * (attempt + 1)` | +| `ExponentialBackoff(base, max)` | `base * 2^attempt` with ±20% jitter | +| `FibonacciBackoff(base, max)` | `base * fib(attempt)` | +| `ExponentialBackoffFullJitter(base, max)` | `random(0, base * 2^attempt)` | +| `ExponentialBackoffEqualJitter(base, max)` | `base * 2^attempt / 2 + random(0, half)` | +| `DecorrelatedJitterBackoff(base, max)` | AWS-style decorrelated jitter | + +Composable with `WithJitter()`, `WithMin()`, `WithMax()`. + +### Circuit Breaker + +```go +client := rhttp.New( + rhttp.WithMiddleware( + rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 5, // Open after 5 consecutive failures + ResetTimeout: 30*time.Second, // Try half-open after 30s + IsFailure: rhttp.DefaultIsFailure, // Errors + 5xx + }), + ), +) +``` + +State machine: `Closed → Open → Half-Open → Closed/Open` + +Returns `rhttp.ErrCircuitOpen` when circuit is open. + +### Rate Limiting + +```go +// Token bucket: 100 requests/second, burst of 10 +limiter := rhttp.NewTokenBucket(100, 10) + +client := rhttp.New( + rhttp.WithMiddleware( + rhttp.RateLimit(rhttp.RateLimitConfig{ + Limiter: limiter, + WaitOnLimit: true, // Block until token available + RespectRetryAfter: true, // Honor Retry-After header + }), + ), +) + +// Per-host rate limiting +perHostLimiter := rhttp.NewPerHostRateLimiter(50, 5) // 50 req/s per host +``` + +### Logging + +```go +client := rhttp.New( + rhttp.WithMiddleware( + rhttp.Logging(rhttp.LoggingConfig{ + Logger: rhttp.LoggerFunc(func(e rhttp.LogEntry) { + log.Printf("%s %s %d %v", e.Method, e.URL, e.StatusCode, e.Duration) + }), + ShouldLog: func(req *http.Request, resp *http.Response, err error) bool { + return err != nil || resp.StatusCode >= 500 // Only log errors + }, + }), + ), +) +``` + +### Metrics + +```go +client := rhttp.New( + rhttp.WithMiddleware( + rhttp.Metrics(rhttp.MetricsConfig{ + Recorder: rhttp.MetricsRecorderFunc(func(e rhttp.MetricEvent) { + // Send to Prometheus, StatsD, etc. + myCounter.WithLabels(e.Method, e.Host, e.StatusCode).Inc() + myHistogram.Observe(e.Duration.Seconds()) + }), + }), + ), +) +``` + +`MetricEvent` fields: `Method`, `Host`, `Path`, `StatusCode`, `Duration`, `BytesSent`, `BytesReceived`, `Error`, `Success` + +## Error Classification + +```go +resp, err := client.Do(ctx, req) +if err != nil { + classified := rhttp.Classify(err) + + switch classified.Kind { + case rhttp.ErrKindTimeout: + // Request timed out + case rhttp.ErrKindCancelled: + // Context was cancelled + case rhttp.ErrKindConnection: + // Connection refused, reset, etc. + case rhttp.ErrKindDNS: + // DNS resolution failed + case rhttp.ErrKindTLS: + // Certificate error + case rhttp.ErrKindTemporary: + // Temporary error, may resolve on retry + } + + // Or use helpers + if rhttp.IsRetryable(err) { + // Safe to retry (timeout, connection, DNS, temporary) + } +} +``` + +## Middleware Order + +Middleware executes in the order specified: + +```go +client := rhttp.New( + rhttp.WithMiddleware( + rhttp.Logging(...), // 1. Log request start + 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 + ), +) +``` + +Recommended order: `Logging → Metrics → Timeout → RateLimit → CircuitBreaker → Retry` + +## Custom Transport + +```go +// Use custom transport +client := rhttp.New( + rhttp.WithTransport(&http.Transport{ + MaxIdleConns: 200, + MaxIdleConnsPerHost: 20, + IdleConnTimeout: 90*time.Second, + }), +) + +// Or use optimized default +transport := rhttp.DefaultTransport() // HTTP/2 enabled, optimized pool +``` + +## Object Pooling + +Reduce allocations with buffer pooling: + +```go +// Get a buffer from the pool +buf := rhttp.GetBuffer() +defer rhttp.PutBuffer(buf) + +buf.WriteString("request body") +``` + +## Benchmarks + +``` +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 +``` + +**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 + +## Design Principles + +1. **No global state** - Each client is independent +2. **Context-first** - All operations respect context cancellation +3. **Fail fast** - Explicit errors, no silent failures +4. **Composable** - Mix and match middleware +5. **Testable** - All components are mockable +6. **Zero dependencies** - Only Go standard library + +## API Reference + +See [pkg.go.dev](https://pkg.go.dev/github.com/oswaldom-code/rhttp) for full API documentation. + +## Development + +### Prerequisites + +```bash +# Install development tools +make install-tools +``` + +### Available Commands + +```bash +make help # Show all available commands +make test # Run unit tests +make test-race # Run tests with race detector +make test-coverage # Generate coverage report +make bench # Run benchmarks +make lint # Run golangci-lint +make fmt # Format code +make vet # Run go vet +make check # Run all checks (fmt, vet, lint, test) +make docs # Serve documentation locally +make clean # Clean build artifacts +``` + +### CI Pipeline + +The project uses GitHub Actions for CI with: + +- Tests on Go 1.21, 1.22, and 1.23 +- Race detector enabled +- golangci-lint for code quality +- Coverage reporting +- Benchmark tracking on PRs + +## Contributing + +Contributions are welcome! Please ensure: + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/my-feature` +3. Run checks: `make check` +4. Commit changes: `git commit -m 'Add my feature'` +5. Push: `git push origin feature/my-feature` +6. Open a Pull Request + +All PRs must pass CI checks before merging. + +## Roadmap + +> **Status:** Phase 1 complete. Phase 2 is the current focus. + +### Phase 1: Foundation (Completed) + +- [x] **Middleware architecture** - Composable, chained `http.RoundTripper` +- [x] **Functional options** - Configuration via `WithXxx()` +- [x] **Optimized transport** - HTTP/2, tuned connection pooling and timeouts +- [x] **Timeout middleware** - Context-aware, respects shorter deadlines +- [x] **Retry middleware** - Idempotency-safe with body replay +- [x] **Backoff strategies** - Constant, linear, exponential, Fibonacci, jitter variants +- [x] **Circuit breaker** - Closed/Open/Half-Open state machine +- [x] **Rate limiting** - Token bucket + per-host limiter +- [x] **Logging middleware** - Pluggable `Logger` interface +- [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 + +- [ ] **Circuit breaker per endpoint** - Separate circuit state for each host/path +- [ ] **Sliding window statistics** - Time-based failure rate calculation +- [ ] **Bulkhead pattern** - Resource isolation per service +- [ ] **Retry budget** - Limit retries per time window +- [ ] **Hedged requests** - Send duplicate request if first is slow +- [ ] **Adaptive timeout** - Adjust timeout based on latency percentiles + +### Phase 3: Observability + +- [ ] **OpenTelemetry integration** - Native tracing and metrics +- [ ] **slog compatibility** - Structured logging (Go 1.21+) +- [ ] **Prometheus metrics** - Out-of-the-box histograms and counters +- [ ] **Distributed tracing** - Automatic trace context propagation +- [ ] **Health check endpoints** - Readiness/liveness probes + +### Phase 4: Developer Experience + +- [ ] **Auto marshaling** - JSON, XML, Protocol Buffers, MessagePack +- [ ] **OAuth2 support** - Automatic token refresh +- [ ] **Debug mode** - Request/response dump, curl generation +- [ ] **Response validation** - JSON Schema, status assertions +- [ ] **Multipart uploads** - With progress callbacks + +### Phase 5: Advanced Features + +- [ ] **Load balancing** - Round-robin, weighted, least connections +- [ ] **Service discovery** - DNS SRV, Kubernetes, Consul +- [ ] **Response caching** - RFC 7234 compliant, pluggable backends +- [ ] **Request coalescing** - Single-flight for duplicate requests +- [ ] **HTTP/3 support** - QUIC protocol (optional) +- [ ] **Connection warm-up** - Pre-establish connections + +### Phase 6: Enterprise + +- [ ] **mTLS support** - Mutual TLS authentication +- [ ] **Certificate pinning** - Enhanced security +- [ ] **Secrets management** - Vault integration +- [ ] **Configuration hot-reload** - Runtime tuning +- [ ] **Chaos engineering** - Fault injection for testing + +--- + +Want to contribute? Check the issues labeled `good first issue` or `help wanted`. + +## License + +MIT License - see [LICENSE](LICENSE) file. diff --git a/backoff.go b/backoff.go new file mode 100644 index 0000000..3e656bc --- /dev/null +++ b/backoff.go @@ -0,0 +1,162 @@ +package rhttp + +import ( + "math/rand" + "sync" + "time" +) + +// BackoffFunc returns the duration to wait before the nth retry attempt. +// attempt is 0-indexed (0 = first retry, 1 = second retry, etc.) +type BackoffFunc func(attempt int) time.Duration + +// ConstantBackoff returns a backoff function that always returns the same duration. +func ConstantBackoff(d time.Duration) BackoffFunc { + return func(_ int) time.Duration { + return d + } +} + +// LinearBackoff returns a backoff function with linear growth. +// The wait time is: base * (attempt + 1), capped at maxDuration. +func LinearBackoff(base, maxDuration time.Duration) BackoffFunc { + return func(attempt int) time.Duration { + backoff := base * time.Duration(attempt+1) + if backoff > maxDuration { + return maxDuration + } + return backoff + } +} + +// ExponentialBackoff returns a backoff function with exponential growth and jitter. +// The wait time is: base * 2^attempt with ±20% jitter, capped at maxDuration. +func ExponentialBackoff(base, maxDuration time.Duration) BackoffFunc { + return func(attempt int) time.Duration { + backoff := base * (1 << attempt) + backoff = min(backoff, maxDuration) + // Add jitter: ±20% (not crypto, just randomization for backoff distribution) + jitter := float64(backoff) * 0.2 * (rand.Float64()*2 - 1) //nolint:gosec + return backoff + time.Duration(jitter) + } +} + +// FibonacciBackoff returns a backoff function based on the Fibonacci sequence. +// The wait time is: base * fib(attempt + 1), capped at maxDuration. +// Fibonacci: 1, 1, 2, 3, 5, 8, 13, 21, 34, 55... +func FibonacciBackoff(base, maxDuration time.Duration) BackoffFunc { + return func(attempt int) time.Duration { + fib := fibonacci(attempt + 1) + backoff := base * time.Duration(fib) + if backoff > maxDuration { + return maxDuration + } + return backoff + } +} + +// fibonacci returns the nth Fibonacci number (1-indexed: 1,1,2,3,5,8...). +func fibonacci(n int) int { + if n <= 2 { + return 1 + } + a, b := 1, 1 + for i := 3; i <= n; i++ { + a, b = b, a+b + } + return b +} + +// DecorrelatedJitterBackoff returns a backoff with decorrelated jitter. +// This algorithm provides better distribution than exponential backoff with jitter. +// See: https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ +// Note: This function returns a stateful BackoffFunc that is safe for concurrent use. +func DecorrelatedJitterBackoff(base, maxDuration time.Duration) BackoffFunc { + var ( + mu sync.Mutex + lastBackoff time.Duration + ) + return func(attempt int) time.Duration { + mu.Lock() + defer mu.Unlock() + + if attempt == 0 { + lastBackoff = base + return base + } + + // Algorithm: sleep = min(cap, random_between(base, sleep * 3)) + minVal := float64(base) + maxVal := float64(lastBackoff) * 3 + backoff := time.Duration(minVal + rand.Float64()*(maxVal-minVal)) //nolint:gosec + + backoff = min(backoff, maxDuration) + lastBackoff = backoff + return backoff + } +} + +// ExponentialBackoffFullJitter returns exponential backoff with full jitter. +// The wait time is: random(0, base * 2^attempt), capped at maxDuration. +// This provides the best spread for avoiding thundering herd. +func ExponentialBackoffFullJitter(base, maxDuration time.Duration) BackoffFunc { + return func(attempt int) time.Duration { + ceiling := base * (1 << attempt) + ceiling = min(ceiling, maxDuration) + return time.Duration(rand.Float64() * float64(ceiling)) //nolint:gosec + } +} + +// ExponentialBackoffEqualJitter returns exponential backoff with equal jitter. +// The wait time is: (base * 2^attempt)/2 + random(0, (base * 2^attempt)/2) +func ExponentialBackoffEqualJitter(base, maxDuration time.Duration) BackoffFunc { + return func(attempt int) time.Duration { + ceiling := base * (1 << attempt) + ceiling = min(ceiling, maxDuration) + half := ceiling / 2 + return half + time.Duration(rand.Float64()*float64(half)) //nolint:gosec + } +} + +// WithJitter wraps a backoff function and adds random jitter. +// jitterFraction should be between 0 and 1 (e.g., 0.2 for ±20% jitter). +func WithJitter(backoff BackoffFunc, jitterFraction float64) BackoffFunc { + if jitterFraction <= 0 { + return backoff + } + if jitterFraction > 1 { + jitterFraction = 1 + } + + return func(attempt int) time.Duration { + d := backoff(attempt) + jitter := float64(d) * jitterFraction * (rand.Float64()*2 - 1) //nolint:gosec + result := d + time.Duration(jitter) + if result < 0 { + return 0 + } + return result + } +} + +// WithMax wraps a backoff function and caps the maximum duration. +func WithMax(backoff BackoffFunc, maxDuration time.Duration) BackoffFunc { + return func(attempt int) time.Duration { + d := backoff(attempt) + if d > maxDuration { + return maxDuration + } + return d + } +} + +// WithMin wraps a backoff function and ensures a minimum duration. +func WithMin(backoff BackoffFunc, minDuration time.Duration) BackoffFunc { + return func(attempt int) time.Duration { + d := backoff(attempt) + if d < minDuration { + return minDuration + } + return d + } +} diff --git a/backoff_test.go b/backoff_test.go new file mode 100644 index 0000000..8f65b30 --- /dev/null +++ b/backoff_test.go @@ -0,0 +1,222 @@ +package rhttp_test + +import ( + "testing" + "time" + + "github.com/oswaldom-code/rhttp" +) + +func TestConstantBackoff(t *testing.T) { + backoff := rhttp.ConstantBackoff(100 * time.Millisecond) + + for attempt := 0; attempt < 10; attempt++ { + d := backoff(attempt) + if d != 100*time.Millisecond { + t.Errorf("attempt %d: expected 100ms, got %v", attempt, d) + } + } +} + +func TestLinearBackoff(t *testing.T) { + backoff := rhttp.LinearBackoff(100*time.Millisecond, 500*time.Millisecond) + + expected := []time.Duration{ + 100 * time.Millisecond, // attempt 0: 100 * 1 + 200 * time.Millisecond, // attempt 1: 100 * 2 + 300 * time.Millisecond, // attempt 2: 100 * 3 + 400 * time.Millisecond, // attempt 3: 100 * 4 + 500 * time.Millisecond, // attempt 4: 100 * 5 = max + 500 * time.Millisecond, // attempt 5: capped at max + } + + for attempt, exp := range expected { + d := backoff(attempt) + if d != exp { + t.Errorf("attempt %d: expected %v, got %v", attempt, exp, d) + } + } +} + +func TestExponentialBackoff_Growth(t *testing.T) { + backoff := rhttp.ExponentialBackoff(100*time.Millisecond, 10*time.Second) + + // Test exponential growth (with tolerance for jitter) + expectedBase := []time.Duration{ + 100 * time.Millisecond, // attempt 0: 100 * 2^0 + 200 * time.Millisecond, // attempt 1: 100 * 2^1 + 400 * time.Millisecond, // attempt 2: 100 * 2^2 + 800 * time.Millisecond, // attempt 3: 100 * 2^3 + } + + for attempt, exp := range expectedBase { + d := backoff(attempt) + // Allow 25% tolerance for jitter + minExpected := time.Duration(float64(exp) * 0.75) + maxExpected := time.Duration(float64(exp) * 1.25) + if d < minExpected || d > maxExpected { + t.Errorf("attempt %d: expected ~%v, got %v", attempt, exp, d) + } + } +} + +func TestExponentialBackoff_Max(t *testing.T) { + backoff := rhttp.ExponentialBackoff(100*time.Millisecond, 500*time.Millisecond) + + // After a few attempts, should be capped at max + d := backoff(10) + // With jitter, should be within ±25% of 500ms + if d > 625*time.Millisecond { + t.Errorf("expected capped at ~500ms, got %v", d) + } +} + +func TestFibonacciBackoff(t *testing.T) { + backoff := rhttp.FibonacciBackoff(100*time.Millisecond, 10*time.Second) + + // Fibonacci: 1, 1, 2, 3, 5, 8, 13... + expected := []time.Duration{ + 100 * time.Millisecond, // attempt 0: fib(1) = 1 + 100 * time.Millisecond, // attempt 1: fib(2) = 1 + 200 * time.Millisecond, // attempt 2: fib(3) = 2 + 300 * time.Millisecond, // attempt 3: fib(4) = 3 + 500 * time.Millisecond, // attempt 4: fib(5) = 5 + 800 * time.Millisecond, // attempt 5: fib(6) = 8 + } + + for attempt, exp := range expected { + d := backoff(attempt) + if d != exp { + t.Errorf("attempt %d: expected %v, got %v", attempt, exp, d) + } + } +} + +func TestFibonacciBackoff_Max(t *testing.T) { + backoff := rhttp.FibonacciBackoff(100*time.Millisecond, 500*time.Millisecond) + + // Should cap at 500ms + d := backoff(10) + if d != 500*time.Millisecond { + t.Errorf("expected capped at 500ms, got %v", d) + } +} + +func TestDecorrelatedJitterBackoff(t *testing.T) { + backoff := rhttp.DecorrelatedJitterBackoff(100*time.Millisecond, 10*time.Second) + + // First attempt should be base + d0 := backoff(0) + if d0 != 100*time.Millisecond { + t.Errorf("attempt 0: expected 100ms, got %v", d0) + } + + // Subsequent attempts should vary and be within bounds + for i := 1; i < 5; i++ { + d := backoff(i) + // Should be positive and not exceed max + if d <= 0 || d > 10*time.Second { + t.Errorf("attempt %d: unexpected duration %v", i, d) + } + } +} + +func TestExponentialBackoffFullJitter(t *testing.T) { + backoff := rhttp.ExponentialBackoffFullJitter(100*time.Millisecond, 10*time.Second) + + for attempt := 0; attempt < 5; attempt++ { + d := backoff(attempt) + ceiling := 100 * time.Millisecond * (1 << attempt) + if ceiling > 10*time.Second { + ceiling = 10 * time.Second + } + + // Full jitter means 0 <= d <= ceiling + if d < 0 || d > ceiling { + t.Errorf("attempt %d: expected 0 <= d <= %v, got %v", attempt, ceiling, d) + } + } +} + +func TestExponentialBackoffEqualJitter(t *testing.T) { + backoff := rhttp.ExponentialBackoffEqualJitter(100*time.Millisecond, 10*time.Second) + + for attempt := 0; attempt < 5; attempt++ { + d := backoff(attempt) + ceiling := 100 * time.Millisecond * (1 << attempt) + if ceiling > 10*time.Second { + ceiling = 10 * time.Second + } + + // Equal jitter means ceiling/2 <= d <= ceiling + half := ceiling / 2 + if d < half || d > ceiling { + t.Errorf("attempt %d: expected %v <= d <= %v, got %v", attempt, half, ceiling, d) + } + } +} + +func TestWithJitter(t *testing.T) { + constant := rhttp.ConstantBackoff(100 * time.Millisecond) + withJitter := rhttp.WithJitter(constant, 0.5) // 50% jitter + + // Run multiple times and check variance + var minD, maxD time.Duration = time.Hour, 0 + for i := 0; i < 100; i++ { + d := withJitter(0) + if d < minD { + minD = d + } + if d > maxD { + maxD = d + } + } + + // With 50% jitter on 100ms, range should be 50ms - 150ms + if minD >= 90*time.Millisecond { + t.Errorf("min %v suggests jitter is not working", minD) + } + if maxD <= 110*time.Millisecond { + t.Errorf("max %v suggests jitter is not working", maxD) + } +} + +func TestWithMax(t *testing.T) { + linear := rhttp.LinearBackoff(100*time.Millisecond, 10*time.Second) + capped := rhttp.WithMax(linear, 300*time.Millisecond) + + // attempt 5 would be 600ms without cap + d := capped(5) + if d != 300*time.Millisecond { + t.Errorf("expected capped at 300ms, got %v", d) + } +} + +func TestWithMin(t *testing.T) { + constant := rhttp.ConstantBackoff(10 * time.Millisecond) + withMin := rhttp.WithMin(constant, 100*time.Millisecond) + + d := withMin(0) + if d != 100*time.Millisecond { + t.Errorf("expected min 100ms, got %v", d) + } +} + +func BenchmarkBackoffStrategies(b *testing.B) { + strategies := map[string]rhttp.BackoffFunc{ + "Constant": rhttp.ConstantBackoff(100 * time.Millisecond), + "Linear": rhttp.LinearBackoff(100*time.Millisecond, 10*time.Second), + "Exponential": rhttp.ExponentialBackoff(100*time.Millisecond, 10*time.Second), + "Fibonacci": rhttp.FibonacciBackoff(100*time.Millisecond, 10*time.Second), + "FullJitter": rhttp.ExponentialBackoffFullJitter(100*time.Millisecond, 10*time.Second), + } + + for name, backoff := range strategies { + b.Run(name, func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = backoff(i % 10) + } + }) + } +} diff --git a/benchmark_test.go b/benchmark_test.go new file mode 100644 index 0000000..7dd1e6d --- /dev/null +++ b/benchmark_test.go @@ -0,0 +1,205 @@ +package rhttp_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/oswaldom-code/rhttp" + "github.com/oswaldom-code/rhttp/internal" +) + +// noopRoundTripper returns immediately with a 200 OK response. +// This isolates the benchmark to measure only client/middleware overhead. +var noopRoundTripper = internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: http.NoBody, + Request: req, + }, nil +}) + +// noopLogger discards all log entries +var noopLogger = rhttp.LoggerFunc(func(rhttp.LogEntry) {}) + +// noopRecorder discards all metric events +var noopRecorder = rhttp.MetricsRecorderFunc(func(rhttp.MetricEvent) {}) + +func BenchmarkClient_Baseline(b *testing.B) { + c := rhttp.New(rhttp.WithTransport(noopRoundTripper)) + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + ctx := context.Background() + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _, _ = c.Do(ctx, req) + } +} + +func BenchmarkClient_WithTimeout(b *testing.B) { + c := rhttp.New( + rhttp.WithTransport(noopRoundTripper), + rhttp.WithMiddleware(rhttp.Timeout(5*time.Second)), + ) + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + ctx := context.Background() + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _, _ = c.Do(ctx, req) + } +} + +func BenchmarkClient_WithRetry(b *testing.B) { + c := rhttp.New( + rhttp.WithTransport(noopRoundTripper), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + })), + ) + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + ctx := context.Background() + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _, _ = c.Do(ctx, req) + } +} + +func BenchmarkClient_WithCircuitBreaker(b *testing.B) { + c := rhttp.New( + rhttp.WithTransport(noopRoundTripper), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 5, + ResetTimeout: 30 * time.Second, + })), + ) + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + ctx := context.Background() + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _, _ = c.Do(ctx, req) + } +} + +func BenchmarkClient_WithLogging(b *testing.B) { + c := rhttp.New( + rhttp.WithTransport(noopRoundTripper), + rhttp.WithMiddleware(rhttp.Logging(rhttp.LoggingConfig{ + Logger: noopLogger, + })), + ) + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + ctx := context.Background() + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _, _ = c.Do(ctx, req) + } +} + +func BenchmarkClient_WithMetrics(b *testing.B) { + c := rhttp.New( + rhttp.WithTransport(noopRoundTripper), + rhttp.WithMiddleware(rhttp.Metrics(rhttp.MetricsConfig{ + Recorder: noopRecorder, + })), + ) + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + ctx := context.Background() + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _, _ = c.Do(ctx, req) + } +} + +func BenchmarkClient_AllMiddleware(b *testing.B) { + c := rhttp.New( + rhttp.WithTransport(noopRoundTripper), + rhttp.WithMiddleware( + rhttp.Timeout(5*time.Second), + rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 5, + ResetTimeout: 30 * time.Second, + }), + rhttp.Retry(rhttp.RetryConfig{MaxAttempts: 3}), + rhttp.Logging(rhttp.LoggingConfig{Logger: noopLogger}), + rhttp.Metrics(rhttp.MetricsConfig{Recorder: noopRecorder}), + ), + ) + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + ctx := context.Background() + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _, _ = c.Do(ctx, req) + } +} + +func BenchmarkClient_Parallel(b *testing.B) { + c := rhttp.New( + rhttp.WithTransport(noopRoundTripper), + rhttp.WithMiddleware( + rhttp.Timeout(5*time.Second), + rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 5, + ResetTimeout: 30 * time.Second, + }), + rhttp.Retry(rhttp.RetryConfig{MaxAttempts: 3}), + ), + ) + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + ctx := context.Background() + + b.ResetTimer() + b.ReportAllocs() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _, _ = c.Do(ctx, req) + } + }) +} + +// Comparison with standard http.Client +func BenchmarkStdHttpClient_Baseline(b *testing.B) { + client := &http.Client{Transport: noopRoundTripper} + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + ctx := context.Background() + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + req = req.WithContext(ctx) + _, _ = client.Do(req) + } +} + +func BenchmarkClassify_Error(b *testing.B) { + err := context.DeadlineExceeded + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _ = rhttp.Classify(err) + } +} diff --git a/circuitbreaker.go b/circuitbreaker.go new file mode 100644 index 0000000..06ddff5 --- /dev/null +++ b/circuitbreaker.go @@ -0,0 +1,193 @@ +package rhttp + +import ( + "net/http" + "sync" + "time" +) + +// CircuitState represents the state of a circuit breaker. +type CircuitState int + +const ( + CircuitClosed CircuitState = iota + CircuitOpen + CircuitHalfOpen +) + +// CircuitBreakerConfig configures the circuit breaker middleware. +type CircuitBreakerConfig struct { + // FailureThreshold is the number of consecutive failures before opening the circuit. + FailureThreshold int + + // ResetTimeout is how long to wait in Open state before transitioning to Half-Open. + ResetTimeout time.Duration + + // IsFailure determines if a response/error should count as a failure. + // If nil, any error or 5xx status code is considered a failure. + IsFailure func(resp *http.Response, err error) bool + + // MaxHalfOpenRequests is the number of probe requests allowed concurrently + // while in Half-Open state. If <= 0, defaults to 1 (single-probe). + MaxHalfOpenRequests int + + // SuccessThreshold is the number of consecutive successful probes required + // in Half-Open state to close the circuit. If <= 0, defaults to 1. + SuccessThreshold int +} + +func DefaultIsFailure(resp *http.Response, err error) bool { + if err != nil { + return true + } + if resp != nil && resp.StatusCode >= 500 { + return true + } + return false +} + +// CircuitBreaker returns a middleware that implements the circuit breaker pattern. +func CircuitBreaker(cfg CircuitBreakerConfig) Middleware { + if cfg.FailureThreshold <= 0 { + cfg.FailureThreshold = 5 + } + if cfg.ResetTimeout <= 0 { + cfg.ResetTimeout = 30 * time.Second + } + if cfg.IsFailure == nil { + cfg.IsFailure = DefaultIsFailure + } + if cfg.MaxHalfOpenRequests <= 0 { + cfg.MaxHalfOpenRequests = 1 + } + if cfg.SuccessThreshold <= 0 { + cfg.SuccessThreshold = 1 + } + + cb := &circuitBreaker{ + cfg: cfg, + state: CircuitClosed, + } + + return func(next http.RoundTripper) http.RoundTripper { + cb.next = next + return cb + } +} + +type circuitBreaker struct { + next http.RoundTripper + cfg CircuitBreakerConfig + + mu sync.Mutex + state CircuitState + failures int + lastFailureTime time.Time + halfOpenInFlight int + halfOpenSuccess int +} + +func (cb *circuitBreaker) allowRequest() bool { + cb.mu.Lock() + defer cb.mu.Unlock() + + switch cb.state { + case CircuitClosed: + return true + + case CircuitOpen: + if time.Since(cb.lastFailureTime) >= cb.cfg.ResetTimeout { + cb.state = CircuitHalfOpen + cb.halfOpenSuccess = 0 + cb.halfOpenInFlight = 1 + return true + } + return false + + case CircuitHalfOpen: + if cb.halfOpenInFlight < cb.cfg.MaxHalfOpenRequests { + cb.halfOpenInFlight++ + return true + } + return false + + default: + return true + } +} + +func (cb *circuitBreaker) recordClosedResult(isFailure bool) { + if !isFailure { + cb.failures = 0 + return + } + + cb.failures++ + cb.lastFailureTime = time.Now() + if cb.failures >= cb.cfg.FailureThreshold { + cb.state = CircuitOpen + } +} + +func (cb *circuitBreaker) recordHalfOpenResult(isFailure bool) { + if cb.halfOpenInFlight > 0 { + cb.halfOpenInFlight-- + } + + if isFailure { + cb.state = CircuitOpen + cb.lastFailureTime = time.Now() + cb.failures = cb.cfg.FailureThreshold + cb.halfOpenSuccess = 0 + return + } + + cb.halfOpenSuccess++ + if cb.halfOpenSuccess < cb.cfg.SuccessThreshold { + return + } + + cb.state = CircuitClosed + cb.failures = 0 + cb.halfOpenSuccess = 0 + cb.halfOpenInFlight = 0 +} + +func (cb *circuitBreaker) recordResult(resp *http.Response, err error) { + cb.mu.Lock() + defer cb.mu.Unlock() + + isFailure := cb.cfg.IsFailure(resp, err) + + switch cb.state { + case CircuitClosed: + cb.recordClosedResult(isFailure) + + case CircuitHalfOpen: + cb.recordHalfOpenResult(isFailure) + + case CircuitOpen: + // Unreachable: allowRequest rejects requests while Open, so a result + // is never recorded in this state. Handled to keep the switch exhaustive. + } +} + +func (cb *circuitBreaker) RoundTrip(req *http.Request) (*http.Response, error) { + if !cb.allowRequest() { + return nil, ErrCircuitOpen + } + + resp, err := cb.next.RoundTrip(req) + + cb.recordResult(resp, err) + + return resp, err +} + +// State returns the current state of the circuit breaker. +// Useful for monitoring and testing. +func (cb *circuitBreaker) State() CircuitState { + cb.mu.Lock() + defer cb.mu.Unlock() + return cb.state +} diff --git a/circuitbreaker_test.go b/circuitbreaker_test.go new file mode 100644 index 0000000..576fbd7 --- /dev/null +++ b/circuitbreaker_test.go @@ -0,0 +1,581 @@ +package rhttp_test + +import ( + "context" + "errors" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/oswaldom-code/rhttp" + "github.com/oswaldom-code/rhttp/internal" +) + +func TestCircuitBreaker_ClosedState_AllowsRequests(t *testing.T) { + var calls int32 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&calls, 1) + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 3, + })), + ) + + for i := 0; i < 5; i++ { + 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) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", resp.StatusCode) + } + } + + if calls != 5 { + t.Fatalf("expected 5 calls, got %d", calls) + } +} + +func TestCircuitBreaker_OpensAfterFailureThreshold(t *testing.T) { + var calls int32 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&calls, 1) + return nil, errors.New("connection refused") + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 3, + ResetTimeout: 1 * time.Hour, // Long timeout so it stays open + })), + ) + + // First 3 calls should go through and fail + for i := 0; i < 3; i++ { + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + } + + if calls != 3 { + t.Fatalf("expected 3 calls before circuit opens, got %d", calls) + } + + // 4th call should be rejected by circuit breaker + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, err := c.Do(context.Background(), req) + + if !errors.Is(err, rhttp.ErrCircuitOpen) { + t.Fatalf("expected ErrCircuitOpen, got %v", err) + } + + // Transport should not have been called + if calls != 3 { + t.Fatalf("expected 3 calls (circuit should block), got %d", calls) + } +} + +func TestCircuitBreaker_TransitionsToHalfOpenAfterTimeout(t *testing.T) { + var calls int32 + shouldSucceed := false + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&calls, 1) + if shouldSucceed { + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + } + return nil, errors.New("connection refused") + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 2, + ResetTimeout: 50 * time.Millisecond, + })), + ) + + // Trigger circuit open + for i := 0; i < 2; i++ { + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + } + + // Verify circuit is open + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, err := c.Do(context.Background(), req) + if !errors.Is(err, rhttp.ErrCircuitOpen) { + t.Fatalf("expected circuit to be open, got %v", err) + } + + // Wait for reset timeout + time.Sleep(60 * time.Millisecond) + + // Now circuit should be half-open, next request goes through + shouldSucceed = true + req, _ = http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + resp, err := c.Do(context.Background(), req) + + if err != nil { + t.Fatalf("expected request to succeed in half-open state, got %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} + +func TestCircuitBreaker_HalfOpenSuccessCloses(t *testing.T) { + callCount := 0 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + callCount++ + if callCount <= 2 { + return nil, errors.New("connection refused") + } + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 2, + ResetTimeout: 10 * time.Millisecond, + })), + ) + + // Open circuit with failures + for i := 0; i < 2; i++ { + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + } + + // Wait for half-open + time.Sleep(15 * time.Millisecond) + + // Success in half-open should close circuit + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + + // Circuit should be closed, multiple requests should work + for i := 0; i < 3; i++ { + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + resp, err := c.Do(context.Background(), req) + if err != nil { + t.Fatalf("expected success after circuit closed, got %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + } +} + +func TestCircuitBreaker_HalfOpenFailureReopens(t *testing.T) { + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("connection refused") + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 2, + ResetTimeout: 10 * time.Millisecond, + })), + ) + + // Open circuit + for i := 0; i < 2; i++ { + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + } + + // Wait for half-open + time.Sleep(15 * time.Millisecond) + + // Failure in half-open should reopen circuit + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + + // Next request should be rejected (circuit reopened) + req, _ = http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, err := c.Do(context.Background(), req) + + if !errors.Is(err, rhttp.ErrCircuitOpen) { + t.Fatalf("expected circuit to reopen after half-open failure, got %v", err) + } +} + +func TestCircuitBreaker_SuccessResetsFailureCount(t *testing.T) { + callCount := 0 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + callCount++ + // Fail on calls 1, 2, then succeed, then fail on 4, 5 + if callCount <= 2 || callCount >= 4 && callCount <= 5 { + return nil, errors.New("connection refused") + } + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 3, + ResetTimeout: 1 * time.Hour, + })), + ) + + // 2 failures + for i := 0; i < 2; i++ { + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + } + + // 1 success - should reset counter + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + + // 2 more failures - should not open circuit (counter was reset) + for i := 0; i < 2; i++ { + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + } + + // Circuit should still be closed + req, _ = http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, err := c.Do(context.Background(), req) + + // Should not be ErrCircuitOpen (might be connection refused or success) + if errors.Is(err, rhttp.ErrCircuitOpen) { + t.Fatal("circuit should not be open - success should have reset failure count") + } +} + +func TestCircuitBreaker_5xxStatusCountsAsFailure(t *testing.T) { + var calls int32 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&calls, 1) + return &http.Response{StatusCode: http.StatusInternalServerError, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 2, + ResetTimeout: 1 * time.Hour, + })), + ) + + // 2 calls with 500 should open circuit + for i := 0; i < 2; i++ { + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + } + + // 3rd call should be blocked + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, err := c.Do(context.Background(), req) + + if !errors.Is(err, rhttp.ErrCircuitOpen) { + t.Fatalf("expected circuit to open after 5xx responses, got %v", err) + } + if calls != 2 { + t.Fatalf("expected 2 calls, got %d", calls) + } +} + +func TestCircuitBreaker_ThreadSafety(t *testing.T) { + var calls int64 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt64(&calls, 1) + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 100, + })), + ) + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + }() + } + + wg.Wait() + + if calls != 100 { + t.Fatalf("expected 100 concurrent calls to succeed, got %d", calls) + } +} + +// blockingProbe is a transport that fails while half-open is off (to open the +// circuit), then blocks each admitted request inside the transport until +// release is closed, signaling entry on entered. It lets a test hold half-open +// probes in flight to observe concurrent gating. +type blockingProbe struct { + halfOpen atomic.Bool + entered chan struct{} + release chan struct{} + probes int32 +} + +func newBlockingProbe() *blockingProbe { + return &blockingProbe{ + entered: make(chan struct{}, 16), + release: make(chan struct{}), + } +} + +func (b *blockingProbe) rt() internal.RoundTripperFunc { + return func(req *http.Request) (*http.Response, error) { + if !b.halfOpen.Load() { + return nil, errors.New("connection refused") + } + atomic.AddInt32(&b.probes, 1) + b.entered <- struct{}{} + <-b.release + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + } +} + +func openCircuit(t *testing.T, c rhttp.Client, times int) { + t.Helper() + for i := 0; i < times; i++ { + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + } +} + +// Regression: half-open must admit only MaxHalfOpenRequests probes (default 1), +// not every concurrent request. The mutex is released between allowRequest and +// recordResult, so a naive implementation lets all concurrent requests through. +func TestCircuitBreaker_HalfOpenAdmitsSingleProbeByDefault(t *testing.T) { + bp := newBlockingProbe() + c := rhttp.New( + rhttp.WithTransport(bp.rt()), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 2, + ResetTimeout: 10 * time.Millisecond, + })), + ) + + openCircuit(t, c, 2) + time.Sleep(15 * time.Millisecond) + bp.halfOpen.Store(true) + + // One probe transitions to half-open and blocks inside the transport. + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + }() + <-bp.entered // probe is now in flight; state is Half-Open with one probe + + // While the probe is in flight, further requests must be rejected. + for i := 0; i < 5; i++ { + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, err := c.Do(context.Background(), req) + if !errors.Is(err, rhttp.ErrCircuitOpen) { + t.Fatalf("expected ErrCircuitOpen for concurrent probe, got %v", err) + } + } + + close(bp.release) + wg.Wait() + + if got := atomic.LoadInt32(&bp.probes); got != 1 { + t.Fatalf("expected exactly 1 probe to reach the transport, got %d", got) + } +} + +func TestCircuitBreaker_HalfOpenRespectsMaxHalfOpenRequests(t *testing.T) { + const maxProbes = 3 + bp := newBlockingProbe() + c := rhttp.New( + rhttp.WithTransport(bp.rt()), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 2, + ResetTimeout: 10 * time.Millisecond, + MaxHalfOpenRequests: maxProbes, + })), + ) + + openCircuit(t, c, 2) + time.Sleep(15 * time.Millisecond) + bp.halfOpen.Store(true) + + // Admit maxProbes concurrent probes; hold them all in flight. + var wg sync.WaitGroup + for i := 0; i < maxProbes; i++ { + wg.Add(1) + go func() { + defer wg.Done() + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + }() + } + for i := 0; i < maxProbes; i++ { + <-bp.entered + } + + // One more must be rejected: the half-open budget is exhausted. + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + if _, err := c.Do(context.Background(), req); !errors.Is(err, rhttp.ErrCircuitOpen) { + t.Fatalf("expected ErrCircuitOpen once %d probes are in flight, got %v", maxProbes, err) + } + + close(bp.release) + wg.Wait() + + if got := atomic.LoadInt32(&bp.probes); got != maxProbes { + t.Fatalf("expected %d probes to reach the transport, got %d", maxProbes, got) + } +} + +func TestCircuitBreaker_OneSuccessDoesNotCloseWithThreshold(t *testing.T) { + var succeed atomic.Bool + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + if succeed.Load() { + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + } + return nil, errors.New("connection refused") + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 2, + ResetTimeout: 10 * time.Millisecond, + SuccessThreshold: 2, + })), + ) + + openCircuit(t, c, 2) + time.Sleep(15 * time.Millisecond) + + // First half-open probe succeeds (1 of 2 required). + succeed.Store(true) + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + if _, err := c.Do(context.Background(), req); err != nil { + t.Fatalf("first probe should be admitted, got %v", err) + } + + // Still half-open: a failing probe must reopen the circuit immediately. + succeed.Store(false) + req, _ = http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + + req, _ = http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, err := c.Do(context.Background(), req) + if !errors.Is(err, rhttp.ErrCircuitOpen) { + t.Fatalf("one success must not close the circuit when SuccessThreshold=2, got %v", err) + } +} + +func TestCircuitBreaker_ClosesAfterSuccessThreshold(t *testing.T) { + var succeed atomic.Bool + var calls int32 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&calls, 1) + if succeed.Load() { + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + } + return nil, errors.New("connection refused") + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 2, + ResetTimeout: 10 * time.Millisecond, + SuccessThreshold: 2, + })), + ) + + openCircuit(t, c, 2) + time.Sleep(15 * time.Millisecond) + succeed.Store(true) + + // Two sequential half-open successes close the circuit. + for i := 0; i < 2; i++ { + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + if _, err := c.Do(context.Background(), req); err != nil { + t.Fatalf("half-open probe %d should be admitted, got %v", i+1, err) + } + } + + // Closed: a concurrent burst is no longer gated to a single probe. + before := atomic.LoadInt32(&calls) + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + if _, err := c.Do(context.Background(), req); err != nil { + t.Errorf("expected success after circuit closed, got %v", err) + } + }() + } + wg.Wait() + + if got := atomic.LoadInt32(&calls) - before; got != 10 { + t.Fatalf("expected 10 calls to reach the transport once closed, got %d", got) + } +} + +func TestCircuitBreaker_CustomIsFailure(t *testing.T) { + var calls int32 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&calls, 1) + // Return 429 which is not a 5xx + return &http.Response{StatusCode: http.StatusTooManyRequests, Request: req}, nil + }) + + // Custom IsFailure that treats 429 as failure + customIsFailure := func(resp *http.Response, err error) bool { + if err != nil { + return true + } + if resp != nil && (resp.StatusCode >= 500 || resp.StatusCode == 429) { + return true + } + return false + } + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 2, + ResetTimeout: 1 * time.Hour, + IsFailure: customIsFailure, + })), + ) + + // 2 calls with 429 should open circuit + for i := 0; i < 2; i++ { + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + } + + // 3rd call should be blocked + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, err := c.Do(context.Background(), req) + + if !errors.Is(err, rhttp.ErrCircuitOpen) { + t.Fatalf("expected circuit to open with custom IsFailure, got %v", err) + } +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..19dced6 --- /dev/null +++ b/client.go @@ -0,0 +1,44 @@ +package rhttp + +import ( + "context" + "net/http" +) + +// Client defines the interface for executing HTTP requests. +type Client interface { + Do(ctx context.Context, req *http.Request) (*http.Response, error) +} + +type client struct { + rt http.RoundTripper +} + +// New creates a new Client with the given options. +func New(opts ...Option) Client { + cfg := defaultConfig() + for _, opt := range opts { + opt(cfg) + } + + rt := cfg.transport + if rt == nil { + rt = DefaultTransport() + } + + if len(cfg.middleware) > 0 { + rt = chain(rt, cfg.middleware...) + } + + return &client{rt: rt} +} + +// Do executes the request with the configured middleware chain. +func (c *client) Do(ctx context.Context, req *http.Request) (*http.Response, error) { + if req == nil { + return nil, ErrInvalidRequest + } + + req = req.Clone(ctx) + return c.rt.RoundTrip(req) +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..175c157 --- /dev/null +++ b/client_test.go @@ -0,0 +1,76 @@ +package rhttp_test + +import ( + "context" + "net/http" + "testing" + + "github.com/oswaldom-code/rhttp" + "github.com/oswaldom-code/rhttp/internal" +) + +func TestClient_Do(t *testing.T) { + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Request: req, + }, nil + }) + + c := rhttp.New(rhttp.WithTransport(rt)) + + 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) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", resp.StatusCode) + } +} + +func TestClient_Do_NilRequest(t *testing.T) { + c := rhttp.New() + + _, err := c.Do(context.Background(), nil) + if err != rhttp.ErrInvalidRequest { + t.Fatalf("expected ErrInvalidRequest, got: %v", err) + } +} + +func TestClient_MiddlewareChain(t *testing.T) { + var order []int + + mw1 := func(next http.RoundTripper) http.RoundTripper { + return internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + order = append(order, 1) + return next.RoundTrip(req) + }) + } + + mw2 := func(next http.RoundTripper) http.RoundTripper { + return internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + order = append(order, 2) + return next.RoundTrip(req) + }) + } + + base := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + order = append(order, 0) + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(base), + rhttp.WithMiddleware(mw1, mw2), + ) + + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + + // mw1 should execute first, then mw2, then base + if len(order) != 3 || order[0] != 1 || order[1] != 2 || order[2] != 0 { + t.Fatalf("unexpected middleware order: %v", order) + } +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..2de37ed --- /dev/null +++ b/doc.go @@ -0,0 +1,83 @@ +// Package rhttp provides a production-grade HTTP client for Go with built-in +// resiliency patterns. It wraps the standard net/http package with middleware support +// for timeouts, retries, circuit breakers, rate limiting, logging, and metrics. +// +// # Quick Start +// +// Create a client with default settings: +// +// client := rhttp.New() +// resp, err := client.Do(ctx, req) +// +// Create a client with middleware: +// +// 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, +// }), +// ), +// ) +// +// # Middleware +// +// Middleware wraps http.RoundTripper to add cross-cutting concerns. The recommended +// order from outermost to innermost is: +// +// Logging -> Metrics -> Timeout -> RateLimit -> CircuitBreaker -> Retry +// +// Available middleware: +// - [Timeout]: Enforces request timeouts +// - [Retry]: Retries failed requests with configurable backoff +// - [CircuitBreaker]: Prevents cascading failures +// - [RateLimit]: Controls request rate with token bucket algorithm +// - [Logging]: Logs request/response details +// - [Metrics]: Records request metrics +// +// # Fluent API +// +// For a more ergonomic API, use the RequestBuilder: +// +// resp, err := rhttp.R(client). +// SetHeader("Authorization", "Bearer token"). +// SetQueryParam("page", "1"). +// SetBodyJSON(payload). +// Post("https://api.example.com/users") +// +// # Backoff Strategies +// +// Multiple backoff strategies are available for retry configuration: +// - [ConstantBackoff]: Fixed delay between retries +// - [LinearBackoff]: Linearly increasing delay +// - [ExponentialBackoff]: Exponentially increasing delay with jitter +// - [FibonacciBackoff]: Fibonacci sequence based delay +// - [DecorrelatedJitterBackoff]: AWS-recommended jitter algorithm +// - [ExponentialBackoffFullJitter]: Full jitter for thundering herd prevention +// - [ExponentialBackoffEqualJitter]: Equal jitter variant +// +// # Error Classification +// +// Errors are automatically classified using [Classify] to help with retry decisions: +// +// classified := rhttp.Classify(err) +// if classified.Kind == rhttp.ErrKindTimeout { +// // Handle timeout +// } +// +// Helper functions like [IsTimeout], [IsConnection], and [IsRetryable] provide +// convenient error checking. +// +// # Thread Safety +// +// All types in this package are safe for concurrent use unless otherwise noted. +// The [Client] can be shared across goroutines, and middleware implementations +// are designed to be thread-safe. +// +// # Zero Dependencies +// +// This package has no external dependencies beyond the Go standard library, +// making it suitable for projects that require minimal dependency footprint. +package rhttp diff --git a/errorclass.go b/errorclass.go new file mode 100644 index 0000000..748429b --- /dev/null +++ b/errorclass.go @@ -0,0 +1,231 @@ +package rhttp + +import ( + "context" + "crypto/tls" + "errors" + "net" + "net/url" + "strings" +) + +// ErrorKind represents the category of an HTTP client error. +type ErrorKind int + +const ( + // ErrKindUnknown is an unclassified error. + ErrKindUnknown ErrorKind = iota + + // ErrKindTimeout indicates the request timed out. + ErrKindTimeout + + // ErrKindCanceled indicates the request was canceled by the caller. + ErrKindCanceled + + // ErrKindConnection indicates a connection error (refused, reset, etc.). + ErrKindConnection + + // ErrKindDNS indicates a DNS resolution failure. + ErrKindDNS + + // ErrKindTLS indicates a TLS/SSL error. + ErrKindTLS + + // ErrKindTemporary indicates a temporary error that may resolve on retry. + ErrKindTemporary +) + +// String returns a human-readable name for the error kind. +func (k ErrorKind) String() string { + switch k { + case ErrKindTimeout: + return "timeout" + case ErrKindCanceled: + return "canceled" + case ErrKindConnection: + return "connection" + case ErrKindDNS: + return "dns" + case ErrKindTLS: + return "tls" + case ErrKindTemporary: + return "temporary" + default: + return "unknown" + } +} + +// IsRetryable returns true if the error kind is typically safe to retry. +func (k ErrorKind) IsRetryable() bool { + switch k { + case ErrKindTimeout, ErrKindConnection, ErrKindDNS, ErrKindTemporary: + return true + default: + return false + } +} + +// ClassifiedError wraps an error with its classification. +type ClassifiedError struct { + Kind ErrorKind + Err error +} + +// Error returns a string representation of the classified error. +func (e *ClassifiedError) Error() string { + if e.Err == nil { + return e.Kind.String() + " error" + } + return e.Kind.String() + ": " + e.Err.Error() +} + +// Unwrap returns the underlying error, allowing use with errors.Is and errors.As. +func (e *ClassifiedError) Unwrap() error { + return e.Err +} + +// Classify analyzes an error and returns its classification. +func Classify(err error) *ClassifiedError { + if err == nil { + return nil + } + + kind := classifyError(err) + return &ClassifiedError{ + Kind: kind, + Err: err, + } +} + +//nolint:gocognit,gocyclo // error classification inherently requires multiple checks +func classifyError(err error) ErrorKind { + if err == nil { + return ErrKindUnknown + } + + // Check for context errors first + if errors.Is(err, context.DeadlineExceeded) { + return ErrKindTimeout + } + if errors.Is(err, context.Canceled) { + return ErrKindCanceled + } + + // Check for URL errors (often wrap other errors) + var urlErr *url.Error + if errors.As(err, &urlErr) { + if urlErr.Timeout() { + return ErrKindTimeout + } + // Classify the wrapped error + if urlErr.Err != nil { + return classifyError(urlErr.Err) + } + } + + // Check for network errors + var netErr net.Error + if errors.As(err, &netErr) { + if netErr.Timeout() { + return ErrKindTimeout + } + } + + // Check for DNS errors + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return ErrKindDNS + } + + // Check for operation errors (connection refused, etc.) + var opErr *net.OpError + if errors.As(err, &opErr) { + if opErr.Timeout() { + return ErrKindTimeout + } + // Connection errors + if opErr.Op == "dial" || opErr.Op == "read" || opErr.Op == "write" { + return ErrKindConnection + } + } + + // Check for TLS errors + var tlsErr *tls.CertificateVerificationError + if errors.As(err, &tlsErr) { + return ErrKindTLS + } + + // Check error message for common patterns + errMsg := strings.ToLower(err.Error()) + if strings.Contains(errMsg, "connection refused") || + strings.Contains(errMsg, "connection reset") || + strings.Contains(errMsg, "no route to host") || + strings.Contains(errMsg, "network is unreachable") { + return ErrKindConnection + } + if strings.Contains(errMsg, "tls") || + strings.Contains(errMsg, "certificate") || + strings.Contains(errMsg, "x509") { + return ErrKindTLS + } + if strings.Contains(errMsg, "no such host") || + strings.Contains(errMsg, "lookup") { + return ErrKindDNS + } + + return ErrKindUnknown +} + +// IsTimeout returns true if the error is a timeout error. +func IsTimeout(err error) bool { + if err == nil { + return false + } + classified := Classify(err) + return classified.Kind == ErrKindTimeout +} + +// IsCanceled returns true if the error is a cancellation error. +func IsCanceled(err error) bool { + if err == nil { + return false + } + classified := Classify(err) + return classified.Kind == ErrKindCanceled +} + +// IsConnection returns true if the error is a connection error. +func IsConnection(err error) bool { + if err == nil { + return false + } + classified := Classify(err) + return classified.Kind == ErrKindConnection +} + +// IsDNS returns true if the error is a DNS error. +func IsDNS(err error) bool { + if err == nil { + return false + } + classified := Classify(err) + return classified.Kind == ErrKindDNS +} + +// IsTLS returns true if the error is a TLS error. +func IsTLS(err error) bool { + if err == nil { + return false + } + classified := Classify(err) + return classified.Kind == ErrKindTLS +} + +// IsRetryable returns true if the error is typically safe to retry. +func IsRetryable(err error) bool { + if err == nil { + return false + } + classified := Classify(err) + return classified.Kind.IsRetryable() +} diff --git a/errorclass_test.go b/errorclass_test.go new file mode 100644 index 0000000..331a4a4 --- /dev/null +++ b/errorclass_test.go @@ -0,0 +1,268 @@ +package rhttp_test + +import ( + "context" + "errors" + "net" + "net/url" + "testing" + + "github.com/oswaldom-code/rhttp" +) + +func TestClassify_DeadlineExceeded(t *testing.T) { + classified := rhttp.Classify(context.DeadlineExceeded) + + if classified.Kind != rhttp.ErrKindTimeout { + t.Errorf("expected ErrKindTimeout, got %v", classified.Kind) + } + if !errors.Is(classified, context.DeadlineExceeded) { + t.Error("expected Unwrap to return original error") + } +} + +func TestClassify_Canceled(t *testing.T) { + classified := rhttp.Classify(context.Canceled) + + if classified.Kind != rhttp.ErrKindCanceled { + t.Errorf("expected ErrKindCanceled, got %v", classified.Kind) + } +} + +func TestClassify_DNSError(t *testing.T) { + dnsErr := &net.DNSError{ + Err: "no such host", + Name: "invalid.example.com", + } + classified := rhttp.Classify(dnsErr) + + if classified.Kind != rhttp.ErrKindDNS { + t.Errorf("expected ErrKindDNS, got %v", classified.Kind) + } +} + +func TestClassify_ConnectionRefused(t *testing.T) { + err := errors.New("dial tcp 127.0.0.1:8080: connection refused") + classified := rhttp.Classify(err) + + if classified.Kind != rhttp.ErrKindConnection { + t.Errorf("expected ErrKindConnection, got %v", classified.Kind) + } +} + +func TestClassify_ConnectionReset(t *testing.T) { + err := errors.New("read tcp: connection reset by peer") + classified := rhttp.Classify(err) + + if classified.Kind != rhttp.ErrKindConnection { + t.Errorf("expected ErrKindConnection, got %v", classified.Kind) + } +} + +func TestClassify_TLSError(t *testing.T) { + err := errors.New("tls: certificate signed by unknown authority") + classified := rhttp.Classify(err) + + if classified.Kind != rhttp.ErrKindTLS { + t.Errorf("expected ErrKindTLS, got %v", classified.Kind) + } +} + +func TestClassify_X509Error(t *testing.T) { + err := errors.New("x509: certificate has expired") + classified := rhttp.Classify(err) + + if classified.Kind != rhttp.ErrKindTLS { + t.Errorf("expected ErrKindTLS, got %v", classified.Kind) + } +} + +func TestClassify_WrappedURLError(t *testing.T) { + urlErr := &url.Error{ + Op: "Get", + URL: "http://example.com", + Err: context.DeadlineExceeded, + } + classified := rhttp.Classify(urlErr) + + if classified.Kind != rhttp.ErrKindTimeout { + t.Errorf("expected ErrKindTimeout for wrapped deadline, got %v", classified.Kind) + } +} + +func TestClassify_URLErrorTimeout(t *testing.T) { + urlErr := &url.Error{ + Op: "Get", + URL: "http://example.com", + Err: &timeoutError{}, + } + classified := rhttp.Classify(urlErr) + + if classified.Kind != rhttp.ErrKindTimeout { + t.Errorf("expected ErrKindTimeout, got %v", classified.Kind) + } +} + +// timeoutError implements net.Error with Timeout() = true +type timeoutError struct{} + +func (e *timeoutError) Error() string { return "timeout" } +func (e *timeoutError) Timeout() bool { return true } +func (e *timeoutError) Temporary() bool { return true } + +func TestClassify_NilError(t *testing.T) { + classified := rhttp.Classify(nil) + + if classified != nil { + t.Error("expected nil for nil error") + } +} + +func TestClassify_UnknownError(t *testing.T) { + err := errors.New("something completely unexpected") + classified := rhttp.Classify(err) + + if classified.Kind != rhttp.ErrKindUnknown { + t.Errorf("expected ErrKindUnknown, got %v", classified.Kind) + } +} + +func TestClassifiedError_Error(t *testing.T) { + err := errors.New("connection refused") + classified := rhttp.Classify(err) + + expected := "connection: connection refused" + if classified.Error() != expected { + t.Errorf("expected %q, got %q", expected, classified.Error()) + } +} + +func TestClassifiedError_Unwrap(t *testing.T) { + originalErr := errors.New("original error") + classified := rhttp.Classify(originalErr) + + if !errors.Is(classified, originalErr) { + t.Error("errors.Is should match original error") + } +} + +func TestErrorKind_String(t *testing.T) { + tests := []struct { + kind rhttp.ErrorKind + expected string + }{ + {rhttp.ErrKindTimeout, "timeout"}, + {rhttp.ErrKindCanceled, "canceled"}, + {rhttp.ErrKindConnection, "connection"}, + {rhttp.ErrKindDNS, "dns"}, + {rhttp.ErrKindTLS, "tls"}, + {rhttp.ErrKindTemporary, "temporary"}, + {rhttp.ErrKindUnknown, "unknown"}, + } + + for _, tt := range tests { + if tt.kind.String() != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, tt.kind.String()) + } + } +} + +func TestErrorKind_IsRetryable(t *testing.T) { + retryable := []rhttp.ErrorKind{ + rhttp.ErrKindTimeout, + rhttp.ErrKindConnection, + rhttp.ErrKindDNS, + rhttp.ErrKindTemporary, + } + for _, k := range retryable { + if !k.IsRetryable() { + t.Errorf("expected %v to be retryable", k) + } + } + + notRetryable := []rhttp.ErrorKind{ + rhttp.ErrKindCanceled, + rhttp.ErrKindTLS, + rhttp.ErrKindUnknown, + } + for _, k := range notRetryable { + if k.IsRetryable() { + t.Errorf("expected %v to not be retryable", k) + } + } +} + +func TestIsTimeout(t *testing.T) { + if !rhttp.IsTimeout(context.DeadlineExceeded) { + t.Error("expected IsTimeout to be true for DeadlineExceeded") + } + if rhttp.IsTimeout(context.Canceled) { + t.Error("expected IsTimeout to be false for Canceled") + } + if rhttp.IsTimeout(nil) { + t.Error("expected IsTimeout to be false for nil") + } +} + +func TestIsCanceled(t *testing.T) { + if !rhttp.IsCanceled(context.Canceled) { + t.Error("expected IsCanceled to be true for Canceled") + } + if rhttp.IsCanceled(context.DeadlineExceeded) { + t.Error("expected IsCanceled to be false for DeadlineExceeded") + } + if rhttp.IsCanceled(nil) { + t.Error("expected IsCanceled to be false for nil") + } +} + +func TestIsConnection(t *testing.T) { + err := errors.New("connection refused") + if !rhttp.IsConnection(err) { + t.Error("expected IsConnection to be true for connection refused") + } + if rhttp.IsConnection(context.Canceled) { + t.Error("expected IsConnection to be false for Canceled") + } +} + +func TestIsDNS(t *testing.T) { + dnsErr := &net.DNSError{Err: "no such host", Name: "invalid.example.com"} + if !rhttp.IsDNS(dnsErr) { + t.Error("expected IsDNS to be true for DNSError") + } + if rhttp.IsDNS(context.Canceled) { + t.Error("expected IsDNS to be false for Canceled") + } +} + +func TestIsTLS(t *testing.T) { + err := errors.New("tls: handshake failure") + if !rhttp.IsTLS(err) { + t.Error("expected IsTLS to be true for TLS error") + } + if rhttp.IsTLS(context.Canceled) { + t.Error("expected IsTLS to be false for Canceled") + } +} + +func TestIsRetryable(t *testing.T) { + // Retryable + if !rhttp.IsRetryable(context.DeadlineExceeded) { + t.Error("expected timeout to be retryable") + } + if !rhttp.IsRetryable(errors.New("connection refused")) { + t.Error("expected connection error to be retryable") + } + + // Not retryable + if rhttp.IsRetryable(context.Canceled) { + t.Error("expected canceled to not be retryable") + } + if rhttp.IsRetryable(errors.New("tls: certificate error")) { + t.Error("expected TLS error to not be retryable") + } + if rhttp.IsRetryable(nil) { + t.Error("expected nil to not be retryable") + } +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..45d9828 --- /dev/null +++ b/errors.go @@ -0,0 +1,14 @@ +package rhttp + +import "errors" + +var ( + // ErrInvalidRequest is returned when a nil request is passed to Do. + ErrInvalidRequest = errors.New("rhttp: invalid request") + + // ErrCircuitOpen is returned when the circuit breaker is open. + ErrCircuitOpen = errors.New("rhttp: circuit breaker is open") + + // ErrRateLimited is returned when the rate limit is exceeded and WaitOnLimit is false. + ErrRateLimited = errors.New("rhttp: rate limit exceeded") +) diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..27e08e5 --- /dev/null +++ b/example_test.go @@ -0,0 +1,198 @@ +package rhttp_test + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/oswaldom-code/rhttp" +) + +func ExampleNew() { + // Create a basic client with default settings + client := rhttp.New() + + req, _ := http.NewRequest("GET", "https://api.example.com/users", http.NoBody) + resp, err := client.Do(context.Background(), req) + if err != nil { + fmt.Println("request failed:", err) + return + } + defer resp.Body.Close() + + fmt.Println("Status:", resp.StatusCode) +} + +func ExampleNew_withMiddleware() { + // Create a client with timeout, retry, and circuit breaker + client := rhttp.New( + rhttp.WithMiddleware( + rhttp.Timeout(5*time.Second), + rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + Backoff: rhttp.ExponentialBackoff(100*time.Millisecond, 5*time.Second), + }), + rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 5, + ResetTimeout: 30 * time.Second, + }), + ), + ) + + req, _ := http.NewRequest("GET", "https://api.example.com/users", http.NoBody) + resp, err := client.Do(context.Background(), req) + if err != nil { + fmt.Println("request failed:", err) + return + } + defer resp.Body.Close() + + fmt.Println("Status:", resp.StatusCode) +} + +func ExampleR() { + client := rhttp.New() + + // Use the fluent API to build and execute requests + resp, err := rhttp.R(client). + SetHeader("Authorization", "Bearer token"). + SetQueryParam("page", "1"). + Get("https://api.example.com/users") + + if err != nil { + fmt.Println("request failed:", err) + return + } + defer resp.Body.Close() + + fmt.Println("Status:", resp.StatusCode) +} + +func ExampleRequestBuilder_SetBodyJSON() { + client := rhttp.New() + + type User struct { + Name string `json:"name"` + Email string `json:"email"` + } + + user := User{Name: "John", Email: "john@example.com"} + + resp, err := rhttp.R(client). + SetBodyJSON(user). + Post("https://api.example.com/users") + + if err != nil { + fmt.Println("request failed:", err) + return + } + defer resp.Body.Close() + + fmt.Println("Status:", resp.StatusCode) +} + +func ExampleRequestBuilder_SetPathParam() { + client := rhttp.New() + + // Path parameters are replaced in the URL template + resp, err := rhttp.R(client). + SetPathParam("id", "123"). + Get("https://api.example.com/users/{id}") + + if err != nil { + fmt.Println("request failed:", err) + return + } + defer resp.Body.Close() + + // Request was made to: https://api.example.com/users/123 + fmt.Println("Status:", resp.StatusCode) +} + +func ExampleClassify() { + client := rhttp.New( + rhttp.WithMiddleware( + rhttp.Timeout(100 * time.Millisecond), + ), + ) + + req, _ := http.NewRequest("GET", "https://slow-api.example.com", http.NoBody) + _, err := client.Do(context.Background(), req) + + if err != nil { + classified := rhttp.Classify(err) + fmt.Printf("Error kind: %s, Retryable: %v\n", + classified.Kind, classified.Kind.IsRetryable()) + } +} + +func ExampleExponentialBackoff() { + backoff := rhttp.ExponentialBackoff(100*time.Millisecond, 10*time.Second) + + // Backoff durations increase exponentially with jitter + fmt.Println("Attempt 0:", backoff(0)) // ~100ms + fmt.Println("Attempt 1:", backoff(1)) // ~200ms + fmt.Println("Attempt 2:", backoff(2)) // ~400ms +} + +func ExampleNewTokenBucket() { + // Allow 10 requests per second with burst of 5 + limiter := rhttp.NewTokenBucket(10, 5) + + // Use with rate limit middleware + client := rhttp.New( + rhttp.WithMiddleware( + rhttp.RateLimit(rhttp.RateLimitConfig{ + Limiter: limiter, + WaitOnLimit: true, + }), + ), + ) + + _ = client // Use client for requests +} + +func ExampleCircuitBreaker() { + // Circuit breaker opens after 5 failures + // and stays open for 30 seconds before trying again + client := rhttp.New( + rhttp.WithMiddleware( + rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 5, + ResetTimeout: 30 * time.Second, + }), + ), + ) + + _ = client // Use client for requests +} + +func ExampleLogging() { + // Custom logger that prints request/response details + logger := rhttp.LoggerFunc(func(entry rhttp.LogEntry) { + fmt.Printf("%s %s -> %d (%s)\n", + entry.Method, entry.URL, entry.StatusCode, entry.Duration) + }) + + client := rhttp.New( + rhttp.WithMiddleware( + rhttp.Logging(rhttp.LoggingConfig{ + Logger: logger, + }), + ), + ) + + _ = client // Use client for requests +} + +func ExampleGetBuffer() { + // Get a buffer from the pool + buf := rhttp.GetBuffer() + + // Use the buffer + buf.WriteString("Hello, World!") + + // Return to pool when done + rhttp.PutBuffer(buf) +} diff --git a/examples/basic/main.go b/examples/basic/main.go new file mode 100644 index 0000000..72b98e6 --- /dev/null +++ b/examples/basic/main.go @@ -0,0 +1,157 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "os" + "time" + + "github.com/oswaldom-code/rhttp" +) + +func main() { + // Create a client with middleware chain: + // Timeout -> CircuitBreaker -> Retry + client := rhttp.New( + rhttp.WithMiddleware( + rhttp.Timeout(10*time.Second), + rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ + FailureThreshold: 5, + ResetTimeout: 30 * time.Second, + }), + rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + Backoff: rhttp.ExponentialBackoff(100*time.Millisecond, 5*time.Second), + }), + ), + ) + + // Example 1: Simple GET request + fmt.Println("=== Example 1: Simple GET ===") + simpleGet(client) + + // Example 2: GET with query parameters + fmt.Println("\n=== Example 2: GET with Query Params ===") + getWithQueryParams(client) + + // Example 3: POST with JSON body + fmt.Println("\n=== Example 3: POST with JSON ===") + postJSON(client) + + // Example 4: Using path parameters + fmt.Println("\n=== Example 4: Path Parameters ===") + pathParams(client) + + // Example 5: Custom headers and timeout + fmt.Println("\n=== Example 5: Custom Headers ===") + customHeaders(client) +} + +func simpleGet(client rhttp.Client) { + resp, err := rhttp.R(client). + Get("https://httpbin.org/get") + if err != nil { + log.Printf("Error: %v", err) + return + } + defer resp.Body.Close() + + fmt.Printf("Status: %s\n", resp.Status) + printBody(resp.Body) +} + +func getWithQueryParams(client rhttp.Client) { + resp, err := rhttp.R(client). + SetQueryParam("page", "1"). + SetQueryParam("limit", "10"). + SetQueryParams(map[string]string{ + "sort": "created_at", + "order": "desc", + }). + Get("https://httpbin.org/get") + if err != nil { + log.Printf("Error: %v", err) + return + } + defer resp.Body.Close() + + fmt.Printf("Status: %s\n", resp.Status) + printBody(resp.Body) +} + +func postJSON(client rhttp.Client) { + payload := map[string]any{ + "name": "rhttp", + "type": "library", + "tags": []string{"http", "resilience", "go"}, + } + + resp, err := rhttp.R(client). + SetBodyJSON(payload). + Post("https://httpbin.org/post") + if err != nil { + log.Printf("Error: %v", err) + return + } + defer resp.Body.Close() + + fmt.Printf("Status: %s\n", resp.Status) + printBody(resp.Body) +} + +func pathParams(client rhttp.Client) { + // Simulates: GET /users/123/posts/456 + resp, err := rhttp.R(client). + SetPathParam("userId", "123"). + SetPathParam("postId", "456"). + Get("https://httpbin.org/anything/users/{userId}/posts/{postId}") + if err != nil { + log.Printf("Error: %v", err) + return + } + defer resp.Body.Close() + + fmt.Printf("Status: %s\n", resp.Status) + printBody(resp.Body) +} + +func customHeaders(client rhttp.Client) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resp, err := rhttp.R(client). + Context(ctx). + SetHeader("X-Custom-Header", "custom-value"). + SetHeader("X-Request-ID", "req-12345"). + SetUserAgent("rhttp-example/1.0"). + SetAccept("application/json"). + Get("https://httpbin.org/headers") + if err != nil { + log.Printf("Error: %v", err) + return + } + defer resp.Body.Close() + + fmt.Printf("Status: %s\n", resp.Status) + printBody(resp.Body) +} + +func printBody(body io.Reader) { + data, err := io.ReadAll(body) + if err != nil { + log.Printf("Error reading body: %v", err) + return + } + + var prettyJSON map[string]any + if err := json.Unmarshal(data, &prettyJSON); err == nil { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.Encode(prettyJSON) + } else { + fmt.Println(string(data)) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..363c9bd --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/oswaldom-code/rhttp + +go 1.21 diff --git a/internal/roundtripper.go b/internal/roundtripper.go new file mode 100644 index 0000000..0f9465d --- /dev/null +++ b/internal/roundtripper.go @@ -0,0 +1,13 @@ +// Package internal provides internal utilities for the rhttp package. +package internal + +import "net/http" + +// RoundTripperFunc is an adapter that allows ordinary functions to be used +// as http.RoundTripper. This is useful for creating mock transports in tests. +type RoundTripperFunc func(*http.Request) (*http.Response, error) + +// RoundTrip implements the http.RoundTripper interface. +func (f RoundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} diff --git a/logging.go b/logging.go new file mode 100644 index 0000000..a6c1184 --- /dev/null +++ b/logging.go @@ -0,0 +1,96 @@ +package rhttp + +import ( + "net/http" + "time" +) + +// Logger is the interface for logging HTTP requests. +// Implement this interface to integrate with your logging library. +type Logger interface { + Log(entry LogEntry) +} + +// LogEntry contains information about an HTTP request/response. +type LogEntry struct { + // Request info + Method string + URL string + + // Response info (nil values if request failed) + StatusCode int + Duration time.Duration + + // Error if request failed + Error error +} + +// LoggerFunc is an adapter to allow ordinary functions to be used as Logger. +type LoggerFunc func(LogEntry) + +func (f LoggerFunc) Log(entry LogEntry) { + f(entry) +} + +// LoggingConfig configures the logging middleware. +type LoggingConfig struct { + // Logger is the logger to use. Required. + Logger Logger + + // ShouldLog determines if a request/response should be logged. + // If nil, all requests are logged. + ShouldLog func(req *http.Request, resp *http.Response, err error) bool +} + +// Logging returns a middleware that logs HTTP requests and responses. +func Logging(cfg LoggingConfig) Middleware { + if cfg.Logger == nil { + // No-op if no logger provided + return func(next http.RoundTripper) http.RoundTripper { + return next + } + } + + if cfg.ShouldLog == nil { + cfg.ShouldLog = func(*http.Request, *http.Response, error) bool { return true } + } + + return func(next http.RoundTripper) http.RoundTripper { + return loggingRoundTripper{ + next: next, + cfg: cfg, + } + } +} + +type loggingRoundTripper struct { + next http.RoundTripper + cfg LoggingConfig +} + +func (l loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + start := time.Now() + + resp, err := l.next.RoundTrip(req) + + duration := time.Since(start) + + if !l.cfg.ShouldLog(req, resp, err) { + return resp, err + } + + entry := LogEntry{ + Method: req.Method, + URL: req.URL.String(), + Duration: duration, + Error: err, + } + + if resp != nil { + entry.StatusCode = resp.StatusCode + } + + l.cfg.Logger.Log(entry) + + return resp, err +} diff --git a/logging_test.go b/logging_test.go new file mode 100644 index 0000000..7fe9bb3 --- /dev/null +++ b/logging_test.go @@ -0,0 +1,211 @@ +package rhttp_test + +import ( + "context" + "errors" + "net/http" + "sync" + "testing" + "time" + + "github.com/oswaldom-code/rhttp" + "github.com/oswaldom-code/rhttp/internal" +) + +func TestLogging_LogsSuccessfulRequest(t *testing.T) { + var captured rhttp.LogEntry + logger := rhttp.LoggerFunc(func(entry rhttp.LogEntry) { + captured = entry + }) + + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Logging(rhttp.LoggingConfig{ + Logger: logger, + })), + ) + + req, _ := http.NewRequest(http.MethodGet, "http://example.com/path", http.NoBody) + _, _ = c.Do(context.Background(), req) + + if captured.Method != http.MethodGet { + t.Errorf("expected method GET, got %s", captured.Method) + } + if captured.URL != "http://example.com/path" { + t.Errorf("expected URL http://example.com/path, got %s", captured.URL) + } + if captured.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", captured.StatusCode) + } + if captured.Error != nil { + t.Errorf("expected no error, got %v", captured.Error) + } + if captured.Duration <= 0 { + t.Error("expected positive duration") + } +} + +func TestLogging_LogsFailedRequest(t *testing.T) { + var captured rhttp.LogEntry + logger := rhttp.LoggerFunc(func(entry rhttp.LogEntry) { + captured = entry + }) + + expectedErr := errors.New("connection refused") + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return nil, expectedErr + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Logging(rhttp.LoggingConfig{ + Logger: logger, + })), + ) + + req, _ := http.NewRequest(http.MethodPost, "http://example.com/api", http.NoBody) + _, _ = c.Do(context.Background(), req) + + if captured.Method != http.MethodPost { + t.Errorf("expected method POST, got %s", captured.Method) + } + if captured.StatusCode != 0 { + t.Errorf("expected status 0 on error, got %d", captured.StatusCode) + } + if captured.Error != expectedErr { + t.Errorf("expected error %v, got %v", expectedErr, captured.Error) + } +} + +func TestLogging_MeasuresDuration(t *testing.T) { + var captured rhttp.LogEntry + logger := rhttp.LoggerFunc(func(entry rhttp.LogEntry) { + captured = entry + }) + + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + time.Sleep(50 * time.Millisecond) + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Logging(rhttp.LoggingConfig{ + Logger: logger, + })), + ) + + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + + if captured.Duration < 50*time.Millisecond { + t.Errorf("expected duration >= 50ms, got %v", captured.Duration) + } +} + +func TestLogging_ShouldLogFilters(t *testing.T) { + var logCount int + logger := rhttp.LoggerFunc(func(entry rhttp.LogEntry) { + logCount++ + }) + + callCount := 0 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + callCount++ + if callCount%2 == 0 { + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + } + return &http.Response{StatusCode: http.StatusInternalServerError, Request: req}, nil + }) + + // Only log errors (5xx) + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Logging(rhttp.LoggingConfig{ + Logger: logger, + ShouldLog: func(req *http.Request, resp *http.Response, err error) bool { + return err != nil || (resp != nil && resp.StatusCode >= 500) + }, + })), + ) + + // Make 4 requests: 500, 200, 500, 200 + for i := 0; i < 4; i++ { + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + } + + // Only 500s should be logged + if logCount != 2 { + t.Errorf("expected 2 logged requests (only errors), got %d", logCount) + } +} + +func TestLogging_NilLoggerIsNoOp(t *testing.T) { + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + // Should not panic with nil logger + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Logging(rhttp.LoggingConfig{ + Logger: nil, + })), + ) + + 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) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} + +func TestLogging_ThreadSafety(t *testing.T) { + var mu sync.Mutex + var entries []rhttp.LogEntry + logger := rhttp.LoggerFunc(func(entry rhttp.LogEntry) { + mu.Lock() + entries = append(entries, entry) + mu.Unlock() + }) + + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Logging(rhttp.LoggingConfig{ + Logger: logger, + })), + ) + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + }() + } + + wg.Wait() + + mu.Lock() + count := len(entries) + mu.Unlock() + + if count != 100 { + t.Errorf("expected 100 log entries, got %d", count) + } +} diff --git a/metrics.go b/metrics.go new file mode 100644 index 0000000..ad72ad4 --- /dev/null +++ b/metrics.go @@ -0,0 +1,96 @@ +package rhttp + +import ( + "net/http" + "time" +) + +// MetricsRecorder is the interface for recording HTTP client metrics. +// Implement this interface to integrate with your metrics system (Prometheus, StatsD, etc.). +type MetricsRecorder interface { + RecordRequest(event MetricEvent) +} + +// MetricEvent contains metrics data for a single HTTP request. +type MetricEvent struct { + // Request info + Method string + Host string + Path string + + // Response info + StatusCode int + Duration time.Duration + BytesSent int64 + BytesReceived int64 + + // Error info + Error error + Success bool +} + +// MetricsRecorderFunc is an adapter to allow ordinary functions as MetricsRecorder. +type MetricsRecorderFunc func(MetricEvent) + +func (f MetricsRecorderFunc) RecordRequest(event MetricEvent) { + f(event) +} + +// MetricsConfig configures the metrics middleware. +type MetricsConfig struct { + // Recorder is the metrics recorder. Required. + Recorder MetricsRecorder +} + +// Metrics returns a middleware that records HTTP client metrics. +func Metrics(cfg MetricsConfig) Middleware { + if cfg.Recorder == nil { + return func(next http.RoundTripper) http.RoundTripper { + return next + } + } + + return func(next http.RoundTripper) http.RoundTripper { + return metricsRoundTripper{ + next: next, + recorder: cfg.Recorder, + } + } +} + +type metricsRoundTripper struct { + next http.RoundTripper + recorder MetricsRecorder +} + +func (m metricsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + start := time.Now() + + resp, err := m.next.RoundTrip(req) + + duration := time.Since(start) + + event := MetricEvent{ + Method: req.Method, + Host: req.URL.Host, + Path: req.URL.Path, + Duration: duration, + Error: err, + Success: err == nil && resp != nil && resp.StatusCode < 500, + } + + if req.ContentLength > 0 { + event.BytesSent = req.ContentLength + } + + if resp != nil { + event.StatusCode = resp.StatusCode + if resp.ContentLength > 0 { + event.BytesReceived = resp.ContentLength + } + } + + m.recorder.RecordRequest(event) + + return resp, err +} diff --git a/metrics_test.go b/metrics_test.go new file mode 100644 index 0000000..89130df --- /dev/null +++ b/metrics_test.go @@ -0,0 +1,267 @@ +package rhttp_test + +import ( + "bytes" + "context" + "errors" + "net/http" + "sync" + "testing" + "time" + + "github.com/oswaldom-code/rhttp" + "github.com/oswaldom-code/rhttp/internal" +) + +func TestMetrics_RecordsSuccessfulRequest(t *testing.T) { + var captured rhttp.MetricEvent + recorder := rhttp.MetricsRecorderFunc(func(event rhttp.MetricEvent) { + captured = event + }) + + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + ContentLength: 1024, + Request: req, + }, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Metrics(rhttp.MetricsConfig{ + Recorder: recorder, + })), + ) + + req, _ := http.NewRequest(http.MethodGet, "http://api.example.com/users", http.NoBody) + _, _ = c.Do(context.Background(), req) + + if captured.Method != http.MethodGet { + t.Errorf("expected method GET, got %s", captured.Method) + } + if captured.Host != "api.example.com" { + t.Errorf("expected host api.example.com, got %s", captured.Host) + } + if captured.Path != "/users" { + t.Errorf("expected path /users, got %s", captured.Path) + } + if captured.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", captured.StatusCode) + } + if !captured.Success { + t.Error("expected Success to be true") + } + if captured.Error != nil { + t.Errorf("expected no error, got %v", captured.Error) + } + if captured.Duration <= 0 { + t.Error("expected positive duration") + } + if captured.BytesReceived != 1024 { + t.Errorf("expected 1024 bytes received, got %d", captured.BytesReceived) + } +} + +func TestMetrics_RecordsFailedRequest(t *testing.T) { + var captured rhttp.MetricEvent + recorder := rhttp.MetricsRecorderFunc(func(event rhttp.MetricEvent) { + captured = event + }) + + expectedErr := errors.New("connection refused") + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return nil, expectedErr + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Metrics(rhttp.MetricsConfig{ + Recorder: recorder, + })), + ) + + req, _ := http.NewRequest(http.MethodPost, "http://api.example.com/data", http.NoBody) + _, _ = c.Do(context.Background(), req) + + if captured.Success { + t.Error("expected Success to be false on error") + } + if captured.Error != expectedErr { + t.Errorf("expected error %v, got %v", expectedErr, captured.Error) + } + if captured.StatusCode != 0 { + t.Errorf("expected status 0 on error, got %d", captured.StatusCode) + } +} + +func TestMetrics_5xxIsNotSuccess(t *testing.T) { + var captured rhttp.MetricEvent + recorder := rhttp.MetricsRecorderFunc(func(event rhttp.MetricEvent) { + captured = event + }) + + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusInternalServerError, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Metrics(rhttp.MetricsConfig{ + Recorder: recorder, + })), + ) + + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + + if captured.Success { + t.Error("expected Success to be false for 5xx") + } + if captured.StatusCode != http.StatusInternalServerError { + t.Errorf("expected status 500, got %d", captured.StatusCode) + } +} + +func TestMetrics_4xxIsSuccess(t *testing.T) { + var captured rhttp.MetricEvent + recorder := rhttp.MetricsRecorderFunc(func(event rhttp.MetricEvent) { + captured = event + }) + + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusNotFound, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Metrics(rhttp.MetricsConfig{ + Recorder: recorder, + })), + ) + + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + + // 4xx is considered "success" from transport perspective (request completed) + if !captured.Success { + t.Error("expected Success to be true for 4xx (transport succeeded)") + } +} + +func TestMetrics_NilRecorderIsNoOp(t *testing.T) { + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Metrics(rhttp.MetricsConfig{ + Recorder: nil, + })), + ) + + 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) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} + +func TestMetrics_RecordsBytesSent(t *testing.T) { + var captured rhttp.MetricEvent + recorder := rhttp.MetricsRecorderFunc(func(event rhttp.MetricEvent) { + captured = event + }) + + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Metrics(rhttp.MetricsConfig{ + Recorder: recorder, + })), + ) + + body := bytes.NewReader([]byte("test payload")) + req, _ := http.NewRequest(http.MethodPost, "http://example.com", body) + req.ContentLength = int64(body.Len()) + _, _ = c.Do(context.Background(), req) + + if captured.BytesSent != 12 { + t.Errorf("expected 12 bytes sent, got %d", captured.BytesSent) + } +} + +func TestMetrics_MeasuresDuration(t *testing.T) { + var captured rhttp.MetricEvent + recorder := rhttp.MetricsRecorderFunc(func(event rhttp.MetricEvent) { + captured = event + }) + + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + time.Sleep(50 * time.Millisecond) + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Metrics(rhttp.MetricsConfig{ + Recorder: recorder, + })), + ) + + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + + if captured.Duration < 50*time.Millisecond { + t.Errorf("expected duration >= 50ms, got %v", captured.Duration) + } +} + +func TestMetrics_ThreadSafety(t *testing.T) { + var mu sync.Mutex + var events []rhttp.MetricEvent + recorder := rhttp.MetricsRecorderFunc(func(event rhttp.MetricEvent) { + mu.Lock() + events = append(events, event) + mu.Unlock() + }) + + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Metrics(rhttp.MetricsConfig{ + Recorder: recorder, + })), + ) + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + }() + } + + wg.Wait() + + mu.Lock() + count := len(events) + mu.Unlock() + + if count != 100 { + t.Errorf("expected 100 metric events, got %d", count) + } +} diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..cf9cd79 --- /dev/null +++ b/middleware.go @@ -0,0 +1,16 @@ +package rhttp + +import "net/http" + +// Middleware wraps an http.RoundTripper to add behavior. +type Middleware func(http.RoundTripper) http.RoundTripper + +// chain applies middleware in reverse order so the first middleware +// in the slice is the outermost wrapper (executes first). +func chain(base http.RoundTripper, mws ...Middleware) http.RoundTripper { + rt := base + for i := len(mws) - 1; i >= 0; i-- { + rt = mws[i](rt) + } + return rt +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..96f46f2 --- /dev/null +++ b/options.go @@ -0,0 +1,33 @@ +package rhttp + +import "net/http" + +// Option configures a Client. +type Option func(*config) + +type config struct { + transport http.RoundTripper + middleware []Middleware +} + +func defaultConfig() *config { + return &config{ + transport: DefaultTransport(), + } +} + +// WithTransport sets a custom http.RoundTripper. +func WithTransport(rt http.RoundTripper) Option { + return func(c *config) { + if rt != nil { + c.transport = rt + } + } +} + +// WithMiddleware appends middleware to the chain. +func WithMiddleware(mw ...Middleware) Option { + return func(c *config) { + c.middleware = append(c.middleware, mw...) + } +} diff --git a/pool.go b/pool.go new file mode 100644 index 0000000..5ec977e --- /dev/null +++ b/pool.go @@ -0,0 +1,97 @@ +package rhttp + +import ( + "bytes" + "sync" +) + +// BufferPool provides reusable byte buffers to reduce allocations. +var BufferPool = &sync.Pool{ + New: func() any { + return bytes.NewBuffer(make([]byte, 0, 4096)) + }, +} + +// GetBuffer retrieves a buffer from the pool. +func GetBuffer() *bytes.Buffer { + buf, _ := BufferPool.Get().(*bytes.Buffer) //nolint:errcheck // type is guaranteed by pool's New func + buf.Reset() + return buf +} + +// PutBuffer returns a buffer to the pool. +func PutBuffer(buf *bytes.Buffer) { + if buf == nil { + return + } + // Don't pool oversized buffers (>64KB) to prevent memory bloat + if buf.Cap() > 65536 { + return + } + buf.Reset() + BufferPool.Put(buf) +} + +// responsePool provides reusable response wrappers. +var responsePool = &sync.Pool{ + New: func() any { + return &Response{} + }, +} + +// Response wraps http.Response with pooling support and convenience methods. +type Response struct { + StatusCode int + Headers map[string][]string + Body []byte + ContentLength int64 + pooled bool +} + +// Reset clears the response for reuse. +func (r *Response) Reset() { + r.StatusCode = 0 + r.Headers = nil + r.Body = nil + r.ContentLength = 0 + r.pooled = false +} + +// Release returns the response to the pool. +// After calling Release, the Response must not be used. +func (r *Response) Release() { + if r == nil || !r.pooled { + return + } + r.Reset() + responsePool.Put(r) +} + +// acquireResponse gets a response from the pool. +// Currently unused but kept for future RequestBuilder enhancements. +func acquireResponse() *Response { //nolint:unused + r, _ := responsePool.Get().(*Response) //nolint:errcheck // type is guaranteed by pool's New func + r.Reset() + r.pooled = true + return r +} + +// IsSuccess returns true if status code is 2xx. +func (r *Response) IsSuccess() bool { + return r.StatusCode >= 200 && r.StatusCode < 300 +} + +// IsError returns true if status code is 4xx or 5xx. +func (r *Response) IsError() bool { + return r.StatusCode >= 400 +} + +// IsServerError returns true if status code is 5xx. +func (r *Response) IsServerError() bool { + return r.StatusCode >= 500 +} + +// IsClientError returns true if status code is 4xx. +func (r *Response) IsClientError() bool { + return r.StatusCode >= 400 && r.StatusCode < 500 +} diff --git a/pool_test.go b/pool_test.go new file mode 100644 index 0000000..39f2014 --- /dev/null +++ b/pool_test.go @@ -0,0 +1,148 @@ +package rhttp_test + +import ( + "sync" + "testing" + + "github.com/oswaldom-code/rhttp" +) + +func TestBufferPool_GetAndPut(t *testing.T) { + buf := rhttp.GetBuffer() + if buf == nil { + t.Fatal("expected non-nil buffer") + } + + buf.WriteString("test data") + if buf.Len() != 9 { + t.Errorf("expected length 9, got %d", buf.Len()) + } + + rhttp.PutBuffer(buf) + + // Get another buffer - should be reset + buf2 := rhttp.GetBuffer() + if buf2.Len() != 0 { + t.Errorf("expected reset buffer with length 0, got %d", buf2.Len()) + } + rhttp.PutBuffer(buf2) +} + +func TestBufferPool_NilSafe(_ *testing.T) { + // Should not panic + rhttp.PutBuffer(nil) +} + +func TestBufferPool_Concurrent(_ *testing.T) { + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + buf := rhttp.GetBuffer() + buf.WriteString("concurrent test") + rhttp.PutBuffer(buf) + }() + } + wg.Wait() +} + +func TestResponse_IsSuccess(t *testing.T) { + tests := []struct { + status int + expected bool + }{ + {200, true}, + {201, true}, + {204, true}, + {299, true}, + {300, false}, + {400, false}, + {500, false}, + } + + for _, tt := range tests { + r := &rhttp.Response{StatusCode: tt.status} + if r.IsSuccess() != tt.expected { + t.Errorf("IsSuccess(%d) = %v, want %v", tt.status, r.IsSuccess(), tt.expected) + } + } +} + +func TestResponse_IsError(t *testing.T) { + tests := []struct { + status int + expected bool + }{ + {200, false}, + {399, false}, + {400, true}, + {404, true}, + {500, true}, + {503, true}, + } + + for _, tt := range tests { + r := &rhttp.Response{StatusCode: tt.status} + if r.IsError() != tt.expected { + t.Errorf("IsError(%d) = %v, want %v", tt.status, r.IsError(), tt.expected) + } + } +} + +func TestResponse_IsServerError(t *testing.T) { + tests := []struct { + status int + expected bool + }{ + {499, false}, + {500, true}, + {502, true}, + {503, true}, + } + + for _, tt := range tests { + r := &rhttp.Response{StatusCode: tt.status} + if r.IsServerError() != tt.expected { + t.Errorf("IsServerError(%d) = %v, want %v", tt.status, r.IsServerError(), tt.expected) + } + } +} + +func TestResponse_IsClientError(t *testing.T) { + tests := []struct { + status int + expected bool + }{ + {399, false}, + {400, true}, + {404, true}, + {499, true}, + {500, false}, + } + + for _, tt := range tests { + r := &rhttp.Response{StatusCode: tt.status} + if r.IsClientError() != tt.expected { + t.Errorf("IsClientError(%d) = %v, want %v", tt.status, r.IsClientError(), tt.expected) + } + } +} + +func BenchmarkBufferPool(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + buf := rhttp.GetBuffer() + buf.WriteString("benchmark test data") + rhttp.PutBuffer(buf) + } +} + +func BenchmarkBufferPool_NoPool(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + buf := make([]byte, 0, 4096) + buf = append(buf, "benchmark test data"...) + _ = buf + } +} diff --git a/ratelimit.go b/ratelimit.go new file mode 100644 index 0000000..c3efa46 --- /dev/null +++ b/ratelimit.go @@ -0,0 +1,231 @@ +package rhttp + +import ( + "context" + "net/http" + "strconv" + "sync" + "time" +) + +// RateLimiter controls the rate of HTTP requests. +type RateLimiter interface { + // Wait blocks until a token is available. + // Deprecated: Use WaitContext for proper cancellation support. + Wait() error + + // WaitContext blocks until a token is available or context is canceled. + // Returns an error if the context is canceled. + WaitContext(ctx context.Context) error + + // TryAcquire attempts to acquire a token without blocking. + // Returns true if a token was acquired, false otherwise. + TryAcquire() bool +} + +// TokenBucket implements a token bucket rate limiter. +type TokenBucket struct { + mu sync.Mutex + tokens float64 + maxTokens float64 + refillRate float64 // tokens per second + lastRefill time.Time +} + +// NewTokenBucket creates a new token bucket rate limiter. +// rate: requests per second allowed +// burst: maximum burst size (bucket capacity) +func NewTokenBucket(rate float64, burst int) *TokenBucket { + return &TokenBucket{ + tokens: float64(burst), + maxTokens: float64(burst), + refillRate: rate, + lastRefill: time.Now(), + } +} + +// Wait blocks until a token is available. +// Deprecated: Use WaitContext for proper cancellation support. +func (tb *TokenBucket) Wait() error { + return tb.WaitContext(context.Background()) +} + +// WaitContext blocks until a token is available or context is canceled. +func (tb *TokenBucket) WaitContext(ctx context.Context) error { + for { + if tb.TryAcquire() { + return nil + } + + // Calculate wait time for next token + tb.mu.Lock() + waitTime := time.Duration((1.0 / tb.refillRate) * float64(time.Second)) + tb.mu.Unlock() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(waitTime): + } + } +} + +// TryAcquire attempts to acquire a token without blocking. +func (tb *TokenBucket) TryAcquire() bool { + tb.mu.Lock() + defer tb.mu.Unlock() + + tb.refill() + + if tb.tokens >= 1 { + tb.tokens-- + return true + } + return false +} + +func (tb *TokenBucket) refill() { + now := time.Now() + elapsed := now.Sub(tb.lastRefill).Seconds() + tb.tokens += elapsed * tb.refillRate + if tb.tokens > tb.maxTokens { + tb.tokens = tb.maxTokens + } + tb.lastRefill = now +} + +// Tokens returns the current number of available tokens. +func (tb *TokenBucket) Tokens() float64 { + tb.mu.Lock() + defer tb.mu.Unlock() + tb.refill() + return tb.tokens +} + +// RateLimitConfig configures the rate limit middleware. +type RateLimitConfig struct { + // Limiter is the rate limiter to use. Required. + Limiter RateLimiter + + // WaitOnLimit if true, waits for a token instead of failing immediately. + // Default is false (fail fast). + WaitOnLimit bool + + // RespectRetryAfter if true, respects Retry-After header from responses. + // Default is false. + RespectRetryAfter bool +} + +// RateLimit returns a middleware that applies rate limiting to requests. +func RateLimit(cfg RateLimitConfig) Middleware { + if cfg.Limiter == nil { + return func(next http.RoundTripper) http.RoundTripper { + return next + } + } + + return func(next http.RoundTripper) http.RoundTripper { + return &rateLimitRoundTripper{ + next: next, + cfg: cfg, + } + } +} + +type rateLimitRoundTripper struct { + next http.RoundTripper + cfg RateLimitConfig + retryLock sync.Mutex + retryAt time.Time +} + +func (r *rateLimitRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + // Check if we're in a Retry-After period + if r.cfg.RespectRetryAfter { + r.retryLock.Lock() + if time.Now().Before(r.retryAt) { + waitTime := time.Until(r.retryAt) + r.retryLock.Unlock() + + select { + case <-req.Context().Done(): + return nil, req.Context().Err() + case <-time.After(waitTime): + } + } else { + r.retryLock.Unlock() + } + } + + // Acquire rate limit token + if r.cfg.WaitOnLimit { + if err := r.cfg.Limiter.WaitContext(req.Context()); err != nil { + return nil, err + } + } else if !r.cfg.Limiter.TryAcquire() { + return nil, ErrRateLimited + } + + resp, err := r.next.RoundTrip(req) + + // Handle Retry-After header + if r.cfg.RespectRetryAfter && resp != nil && resp.StatusCode == http.StatusTooManyRequests { + if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" { + if seconds, err := strconv.Atoi(retryAfter); err == nil { + r.retryLock.Lock() + r.retryAt = time.Now().Add(time.Duration(seconds) * time.Second) + r.retryLock.Unlock() + } else if t, err := http.ParseTime(retryAfter); err == nil { + r.retryLock.Lock() + r.retryAt = t + r.retryLock.Unlock() + } + } + } + + return resp, err +} + +// PerHostRateLimiter provides separate rate limiters for each host. +// It lazily creates a TokenBucket for each unique host on first access. +// This is useful when making requests to multiple APIs with different rate limits. +type PerHostRateLimiter struct { + mu sync.RWMutex + limiters map[string]*TokenBucket + rate float64 + burst int +} + +// NewPerHostRateLimiter creates a rate limiter that applies limits per host. +func NewPerHostRateLimiter(rate float64, burst int) *PerHostRateLimiter { + return &PerHostRateLimiter{ + limiters: make(map[string]*TokenBucket), + rate: rate, + burst: burst, + } +} + +// GetLimiter returns the rate limiter for a specific host. +// If no limiter exists for the host, a new one is created with the configured +// rate and burst values. This method is safe for concurrent use. +func (p *PerHostRateLimiter) GetLimiter(host string) *TokenBucket { + p.mu.RLock() + limiter, ok := p.limiters[host] + p.mu.RUnlock() + + if ok { + return limiter + } + + p.mu.Lock() + defer p.mu.Unlock() + + // Double-check after acquiring write lock + if limiter, ok = p.limiters[host]; ok { + return limiter + } + + limiter = NewTokenBucket(p.rate, p.burst) + p.limiters[host] = limiter + return limiter +} diff --git a/ratelimit_test.go b/ratelimit_test.go new file mode 100644 index 0000000..704a9de --- /dev/null +++ b/ratelimit_test.go @@ -0,0 +1,281 @@ +package rhttp_test + +import ( + "context" + "errors" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/oswaldom-code/rhttp" + "github.com/oswaldom-code/rhttp/internal" +) + +func TestTokenBucket_Basic(t *testing.T) { + tb := rhttp.NewTokenBucket(10, 5) // 10 req/s, burst of 5 + + // Should be able to acquire 5 tokens immediately (burst) + for i := 0; i < 5; i++ { + if !tb.TryAcquire() { + t.Fatalf("expected to acquire token %d", i) + } + } + + // 6th should fail + if tb.TryAcquire() { + t.Fatal("expected 6th acquire to fail") + } +} + +func TestTokenBucket_Refill(t *testing.T) { + tb := rhttp.NewTokenBucket(100, 1) // 100 req/s, burst of 1 + + // Consume the token + if !tb.TryAcquire() { + t.Fatal("expected to acquire initial token") + } + + // Should fail immediately + if tb.TryAcquire() { + t.Fatal("expected acquire to fail immediately after drain") + } + + // Wait for refill (10ms for 1 token at 100/s) + time.Sleep(15 * time.Millisecond) + + // Should succeed after refill + if !tb.TryAcquire() { + t.Fatal("expected to acquire token after refill") + } +} + +func TestTokenBucket_Wait(t *testing.T) { + tb := rhttp.NewTokenBucket(100, 1) // 100 req/s, burst of 1 + + // Consume the token + tb.TryAcquire() + + start := time.Now() + err := tb.Wait() + elapsed := time.Since(start) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should have waited ~10ms + if elapsed < 5*time.Millisecond { + t.Errorf("expected to wait at least 5ms, waited %v", elapsed) + } +} + +func TestTokenBucket_Concurrent(t *testing.T) { + tb := rhttp.NewTokenBucket(1000, 100) + + var acquired int64 + var wg sync.WaitGroup + + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if tb.TryAcquire() { + atomic.AddInt64(&acquired, 1) + } + }() + } + + wg.Wait() + + if acquired != 100 { + t.Errorf("expected 100 acquired, got %d", acquired) + } +} + +func TestRateLimit_Middleware(t *testing.T) { + var calls int32 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&calls, 1) + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + limiter := rhttp.NewTokenBucket(1000, 10) + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.RateLimit(rhttp.RateLimitConfig{ + Limiter: limiter, + WaitOnLimit: true, + })), + ) + + // Should succeed within burst + for i := 0; i < 10; i++ { + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, err := c.Do(context.Background(), req) + if err != nil { + t.Fatalf("request %d failed: %v", i, err) + } + } + + if calls != 10 { + t.Errorf("expected 10 calls, got %d", calls) + } +} + +func TestRateLimit_NoWait(t *testing.T) { + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + limiter := rhttp.NewTokenBucket(1, 1) // 1 req/s, burst of 1 + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.RateLimit(rhttp.RateLimitConfig{ + Limiter: limiter, + WaitOnLimit: false, + })), + ) + + // First should succeed + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, err := c.Do(context.Background(), req) + if err != nil { + t.Fatalf("first request failed: %v", err) + } + + // Second should fail immediately + req, _ = http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, err = c.Do(context.Background(), req) + if !errors.Is(err, rhttp.ErrRateLimited) { + t.Fatalf("expected ErrRateLimited, got %v", err) + } +} + +func TestRateLimit_RespectRetryAfter(t *testing.T) { + callCount := 0 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + callCount++ + if callCount == 1 { + resp := &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: make(http.Header), + Request: req, + } + resp.Header.Set("Retry-After", "1") // 1 second + return resp, nil + } + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + limiter := rhttp.NewTokenBucket(1000, 100) + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.RateLimit(rhttp.RateLimitConfig{ + Limiter: limiter, + WaitOnLimit: true, + RespectRetryAfter: true, + })), + ) + + // First request gets 429 + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + resp, _ := c.Do(context.Background(), req) + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("expected 429, got %d", resp.StatusCode) + } + + // Second request should wait for Retry-After + start := time.Now() + req, _ = http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + resp, _ = c.Do(context.Background(), req) + elapsed := time.Since(start) + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + // Should have waited ~1 second + if elapsed < 900*time.Millisecond { + t.Errorf("expected to wait ~1s for Retry-After, waited %v", elapsed) + } +} + +func TestRateLimit_NilLimiter(t *testing.T) { + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.RateLimit(rhttp.RateLimitConfig{ + Limiter: nil, + })), + ) + + 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) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} + +func TestPerHostRateLimiter(t *testing.T) { + phl := rhttp.NewPerHostRateLimiter(10, 5) + + limiter1 := phl.GetLimiter("api.example.com") + limiter2 := phl.GetLimiter("api.other.com") + limiter3 := phl.GetLimiter("api.example.com") // same as limiter1 + + if limiter1 == limiter2 { + t.Error("expected different limiters for different hosts") + } + + if limiter1 != limiter3 { + t.Error("expected same limiter for same host") + } + + // Drain limiter1 + for i := 0; i < 5; i++ { + limiter1.TryAcquire() + } + + // limiter2 should still have tokens + if !limiter2.TryAcquire() { + t.Error("expected limiter2 to have tokens") + } + + // limiter1 should be empty + if limiter1.TryAcquire() { + t.Error("expected limiter1 to be empty") + } +} + +func BenchmarkTokenBucket_TryAcquire(b *testing.B) { + tb := rhttp.NewTokenBucket(1000000, 1000000) // high limits + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + tb.TryAcquire() + } +} + +func BenchmarkTokenBucket_Concurrent(b *testing.B) { + tb := rhttp.NewTokenBucket(1000000, 1000000) + + b.ResetTimer() + b.ReportAllocs() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + tb.TryAcquire() + } + }) +} diff --git a/request.go b/request.go new file mode 100644 index 0000000..0c5a9e7 --- /dev/null +++ b/request.go @@ -0,0 +1,319 @@ +package rhttp + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "encoding/xml" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// RequestBuilder provides a fluent interface for building HTTP requests. +type RequestBuilder struct { + client Client + ctx context.Context + method string + url string + headers http.Header + queryParams url.Values + pathParams map[string]string + body io.Reader + bodyBytes []byte + timeout time.Duration + err error +} + +// R creates a new RequestBuilder. +func (c *client) R() *RequestBuilder { + return &RequestBuilder{ + client: c, + ctx: context.Background(), + headers: make(http.Header), + queryParams: make(url.Values), + pathParams: make(map[string]string), + } +} + +// R creates a new RequestBuilder from a Client interface. +// Returns nil if the client doesn't support RequestBuilder. +func R(c Client) *RequestBuilder { + if rc, ok := c.(interface{ R() *RequestBuilder }); ok { + return rc.R() + } + // Fallback: create a basic builder + return &RequestBuilder{ + client: c, + ctx: context.Background(), + headers: make(http.Header), + queryParams: make(url.Values), + pathParams: make(map[string]string), + } +} + +// Context sets the context for the request. +func (rb *RequestBuilder) Context(ctx context.Context) *RequestBuilder { + rb.ctx = ctx + return rb +} + +// SetTimeout sets a timeout for this specific request. +func (rb *RequestBuilder) SetTimeout(d time.Duration) *RequestBuilder { + rb.timeout = d + return rb +} + +// SetHeader sets a single header. +func (rb *RequestBuilder) SetHeader(key, value string) *RequestBuilder { + rb.headers.Set(key, value) + return rb +} + +// SetHeaders sets multiple headers from a map. +func (rb *RequestBuilder) SetHeaders(headers map[string]string) *RequestBuilder { + for k, v := range headers { + rb.headers.Set(k, v) + } + return rb +} + +// AddHeader adds a header value (allows multiple values for same key). +func (rb *RequestBuilder) AddHeader(key, value string) *RequestBuilder { + rb.headers.Add(key, value) + return rb +} + +// SetContentType sets the Content-Type header. +func (rb *RequestBuilder) SetContentType(contentType string) *RequestBuilder { + return rb.SetHeader("Content-Type", contentType) +} + +// SetAccept sets the Accept header. +func (rb *RequestBuilder) SetAccept(accept string) *RequestBuilder { + return rb.SetHeader("Accept", accept) +} + +// SetUserAgent sets the User-Agent header. +func (rb *RequestBuilder) SetUserAgent(ua string) *RequestBuilder { + return rb.SetHeader("User-Agent", ua) +} + +// SetAuthToken sets a Bearer token in the Authorization header. +func (rb *RequestBuilder) SetAuthToken(token string) *RequestBuilder { + return rb.SetHeader("Authorization", "Bearer "+token) +} + +// SetBasicAuth sets Basic authentication. +func (rb *RequestBuilder) SetBasicAuth(username, password string) *RequestBuilder { + rb.headers.Set("Authorization", "Basic "+basicAuth(username, password)) + return rb +} + +// SetQueryParam sets a single query parameter. +func (rb *RequestBuilder) SetQueryParam(key, value string) *RequestBuilder { + rb.queryParams.Set(key, value) + return rb +} + +// SetQueryParams sets multiple query parameters from a map. +func (rb *RequestBuilder) SetQueryParams(params map[string]string) *RequestBuilder { + for k, v := range params { + rb.queryParams.Set(k, v) + } + return rb +} + +// AddQueryParam adds a query parameter (allows multiple values for same key). +func (rb *RequestBuilder) AddQueryParam(key, value string) *RequestBuilder { + rb.queryParams.Add(key, value) + return rb +} + +// SetPathParam sets a path parameter to be replaced in the URL. +// Example: SetPathParam("id", "123") replaces {id} in "/users/{id}". +func (rb *RequestBuilder) SetPathParam(key, value string) *RequestBuilder { + rb.pathParams[key] = value + return rb +} + +// SetPathParams sets multiple path parameters from a map. +func (rb *RequestBuilder) SetPathParams(params map[string]string) *RequestBuilder { + for k, v := range params { + rb.pathParams[k] = v + } + return rb +} + +// SetBody sets the request body from a reader. +func (rb *RequestBuilder) SetBody(body io.Reader) *RequestBuilder { + rb.body = body + return rb +} + +// SetBodyBytes sets the request body from bytes. +func (rb *RequestBuilder) SetBodyBytes(body []byte) *RequestBuilder { + rb.bodyBytes = body + rb.body = bytes.NewReader(body) + return rb +} + +// SetBodyString sets the request body from a string. +func (rb *RequestBuilder) SetBodyString(body string) *RequestBuilder { + return rb.SetBodyBytes([]byte(body)) +} + +// SetBodyJSON marshals the value to JSON and sets it as the body. +func (rb *RequestBuilder) SetBodyJSON(v any) *RequestBuilder { + data, err := json.Marshal(v) + if err != nil { + rb.err = err + return rb + } + rb.SetContentType("application/json") + return rb.SetBodyBytes(data) +} + +// SetBodyXML marshals the value to XML and sets it as the body. +func (rb *RequestBuilder) SetBodyXML(v any) *RequestBuilder { + data, err := xml.Marshal(v) + if err != nil { + rb.err = err + return rb + } + rb.SetContentType("application/xml") + return rb.SetBodyBytes(data) +} + +// SetBodyForm sets form data as the body. +func (rb *RequestBuilder) SetBodyForm(data map[string]string) *RequestBuilder { + form := url.Values{} + for k, v := range data { + form.Set(k, v) + } + rb.SetContentType("application/x-www-form-urlencoded") + return rb.SetBodyString(form.Encode()) +} + +// Get executes a GET request. +func (rb *RequestBuilder) Get(url string) (*http.Response, error) { + rb.method = http.MethodGet + rb.url = url + return rb.execute() +} + +// Post executes a POST request. +func (rb *RequestBuilder) Post(url string) (*http.Response, error) { + rb.method = http.MethodPost + rb.url = url + return rb.execute() +} + +// Put executes a PUT request. +func (rb *RequestBuilder) Put(url string) (*http.Response, error) { + rb.method = http.MethodPut + rb.url = url + return rb.execute() +} + +// Patch executes a PATCH request. +func (rb *RequestBuilder) Patch(url string) (*http.Response, error) { + rb.method = http.MethodPatch + rb.url = url + return rb.execute() +} + +// Delete executes a DELETE request. +func (rb *RequestBuilder) Delete(url string) (*http.Response, error) { + rb.method = http.MethodDelete + rb.url = url + return rb.execute() +} + +// Head executes a HEAD request. +func (rb *RequestBuilder) Head(url string) (*http.Response, error) { + rb.method = http.MethodHead + rb.url = url + return rb.execute() +} + +// Options executes an OPTIONS request. +func (rb *RequestBuilder) Options(url string) (*http.Response, error) { + rb.method = http.MethodOptions + rb.url = url + return rb.execute() +} + +// Execute executes the request with the configured method. +func (rb *RequestBuilder) Execute(method, url string) (*http.Response, error) { + rb.method = method + rb.url = url + return rb.execute() +} + +func (rb *RequestBuilder) execute() (*http.Response, error) { + if rb.err != nil { + return nil, rb.err + } + + // Apply path parameters + finalURL := rb.url + for k, v := range rb.pathParams { + finalURL = strings.ReplaceAll(finalURL, "{"+k+"}", url.PathEscape(v)) + } + + // Apply query parameters + if len(rb.queryParams) > 0 { + if strings.Contains(finalURL, "?") { + finalURL += "&" + rb.queryParams.Encode() + } else { + finalURL += "?" + rb.queryParams.Encode() + } + } + + // Create body reader + var bodyReader io.Reader + if rb.body != nil { + bodyReader = rb.body + } + + // Create request + req, err := http.NewRequest(rb.method, finalURL, bodyReader) + if err != nil { + return nil, err + } + + // Set GetBody for retry support + if rb.bodyBytes != nil { + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(rb.bodyBytes)), nil + } + req.ContentLength = int64(len(rb.bodyBytes)) + } + + // Apply headers + for k, vals := range rb.headers { + for _, v := range vals { + req.Header.Add(k, v) + } + } + + // Apply timeout + ctx := rb.ctx + if rb.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, rb.timeout) + defer cancel() + } + + return rb.client.Do(ctx, req) +} + +// basicAuth encodes username and password for Basic authentication. +func basicAuth(username, password string) string { + auth := username + ":" + password + return base64.StdEncoding.EncodeToString([]byte(auth)) +} diff --git a/request_test.go b/request_test.go new file mode 100644 index 0000000..43a4a9d --- /dev/null +++ b/request_test.go @@ -0,0 +1,381 @@ +package rhttp_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/oswaldom-code/rhttp" + "github.com/oswaldom-code/rhttp/internal" +) + +func TestRequestBuilder_Get(t *testing.T) { + var capturedReq *http.Request + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + capturedReq = req + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New(rhttp.WithTransport(rt)) + + resp, err := rhttp.R(c).Get("http://example.com/api") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if capturedReq.Method != http.MethodGet { + t.Errorf("expected GET, got %s", capturedReq.Method) + } + if capturedReq.URL.String() != "http://example.com/api" { + t.Errorf("expected http://example.com/api, got %s", capturedReq.URL.String()) + } +} + +func TestRequestBuilder_Post(t *testing.T) { + var capturedReq *http.Request + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + capturedReq = req + return &http.Response{StatusCode: http.StatusCreated, Request: req}, nil + }) + + c := rhttp.New(rhttp.WithTransport(rt)) + + resp, err := rhttp.R(c). + SetBodyString("test body"). + Post("http://example.com/api") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", resp.StatusCode) + } + if capturedReq.Method != http.MethodPost { + t.Errorf("expected POST, got %s", capturedReq.Method) + } +} + +func TestRequestBuilder_Headers(t *testing.T) { + var capturedReq *http.Request + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + capturedReq = req + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New(rhttp.WithTransport(rt)) + + _, _ = rhttp.R(c). + SetHeader("X-Custom", "value1"). + SetHeaders(map[string]string{ + "X-Another": "value2", + "X-Third": "value3", + }). + AddHeader("X-Multi", "a"). + AddHeader("X-Multi", "b"). + SetContentType("application/json"). + SetAccept("application/json"). + SetUserAgent("test-agent"). + Get("http://example.com") + + if capturedReq.Header.Get("X-Custom") != "value1" { + t.Errorf("expected X-Custom=value1, got %s", capturedReq.Header.Get("X-Custom")) + } + if capturedReq.Header.Get("X-Another") != "value2" { + t.Errorf("expected X-Another=value2, got %s", capturedReq.Header.Get("X-Another")) + } + if capturedReq.Header.Get("Content-Type") != "application/json" { + t.Errorf("expected Content-Type=application/json, got %s", capturedReq.Header.Get("Content-Type")) + } + if capturedReq.Header.Get("Accept") != "application/json" { + t.Errorf("expected Accept=application/json, got %s", capturedReq.Header.Get("Accept")) + } + if capturedReq.Header.Get("User-Agent") != "test-agent" { + t.Errorf("expected User-Agent=test-agent, got %s", capturedReq.Header.Get("User-Agent")) + } + + multiVals := capturedReq.Header.Values("X-Multi") + if len(multiVals) != 2 || multiVals[0] != "a" || multiVals[1] != "b" { + t.Errorf("expected X-Multi=[a,b], got %v", multiVals) + } +} + +func TestRequestBuilder_QueryParams(t *testing.T) { + var capturedReq *http.Request + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + capturedReq = req + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New(rhttp.WithTransport(rt)) + + _, _ = rhttp.R(c). + SetQueryParam("page", "1"). + SetQueryParams(map[string]string{ + "limit": "10", + "sort": "desc", + }). + AddQueryParam("filter", "active"). + AddQueryParam("filter", "verified"). + Get("http://example.com/users") + + query := capturedReq.URL.Query() + if query.Get("page") != "1" { + t.Errorf("expected page=1, got %s", query.Get("page")) + } + if query.Get("limit") != "10" { + t.Errorf("expected limit=10, got %s", query.Get("limit")) + } + if query.Get("sort") != "desc" { + t.Errorf("expected sort=desc, got %s", query.Get("sort")) + } + + filters := query["filter"] + if len(filters) != 2 { + t.Errorf("expected 2 filter values, got %d", len(filters)) + } +} + +func TestRequestBuilder_PathParams(t *testing.T) { + var capturedReq *http.Request + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + capturedReq = req + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New(rhttp.WithTransport(rt)) + + _, _ = rhttp.R(c). + SetPathParam("org", "acme"). + SetPathParams(map[string]string{ + "repo": "api", + "id": "123", + }). + Get("http://example.com/{org}/{repo}/issues/{id}") + + expected := "http://example.com/acme/api/issues/123" + if capturedReq.URL.String() != expected { + t.Errorf("expected %s, got %s", expected, capturedReq.URL.String()) + } +} + +func TestRequestBuilder_SetBodyJSON(t *testing.T) { + var capturedReq *http.Request + var capturedBody []byte + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + capturedReq = req + capturedBody, _ = io.ReadAll(req.Body) + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New(rhttp.WithTransport(rt)) + + payload := map[string]string{"name": "test", "value": "123"} + _, _ = rhttp.R(c). + SetBodyJSON(payload). + Post("http://example.com/api") + + if capturedReq.Header.Get("Content-Type") != "application/json" { + t.Errorf("expected Content-Type=application/json, got %s", capturedReq.Header.Get("Content-Type")) + } + + var decoded map[string]string + if err := json.Unmarshal(capturedBody, &decoded); err != nil { + t.Fatalf("failed to decode JSON body: %v", err) + } + if decoded["name"] != "test" || decoded["value"] != "123" { + t.Errorf("unexpected body: %v", decoded) + } +} + +func TestRequestBuilder_SetBodyForm(t *testing.T) { + var capturedReq *http.Request + var capturedBody string + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + capturedReq = req + body, _ := io.ReadAll(req.Body) + capturedBody = string(body) + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New(rhttp.WithTransport(rt)) + + _, _ = rhttp.R(c). + SetBodyForm(map[string]string{ + "username": "test", + "password": "secret", + }). + Post("http://example.com/login") + + if capturedReq.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { + t.Errorf("expected Content-Type=application/x-www-form-urlencoded, got %s", capturedReq.Header.Get("Content-Type")) + } + + if !strings.Contains(capturedBody, "username=test") { + t.Errorf("expected body to contain username=test, got %s", capturedBody) + } + if !strings.Contains(capturedBody, "password=secret") { + t.Errorf("expected body to contain password=secret, got %s", capturedBody) + } +} + +func TestRequestBuilder_SetAuthToken(t *testing.T) { + var capturedReq *http.Request + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + capturedReq = req + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New(rhttp.WithTransport(rt)) + + _, _ = rhttp.R(c). + SetAuthToken("my-token-123"). + Get("http://example.com/api") + + expected := "Bearer my-token-123" + if capturedReq.Header.Get("Authorization") != expected { + t.Errorf("expected Authorization=%s, got %s", expected, capturedReq.Header.Get("Authorization")) + } +} + +func TestRequestBuilder_SetBasicAuth(t *testing.T) { + var capturedReq *http.Request + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + capturedReq = req + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New(rhttp.WithTransport(rt)) + + _, _ = rhttp.R(c). + SetBasicAuth("user", "pass"). + Get("http://example.com/api") + + auth := capturedReq.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Basic ") { + t.Errorf("expected Authorization to start with 'Basic ', got %s", auth) + } +} + +func TestRequestBuilder_Timeout(t *testing.T) { + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + // Respect context cancellation + select { + case <-req.Context().Done(): + return nil, req.Context().Err() + case <-time.After(200 * time.Millisecond): + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + } + }) + + c := rhttp.New(rhttp.WithTransport(rt)) + + _, err := rhttp.R(c). + SetTimeout(50 * time.Millisecond). + Get("http://example.com/api") + + if err == nil { + t.Fatal("expected timeout error") + } + if !strings.Contains(err.Error(), "context deadline exceeded") { + t.Errorf("expected deadline exceeded error, got %v", err) + } +} + +func TestRequestBuilder_Context(t *testing.T) { + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + // Respect context cancellation + select { + case <-req.Context().Done(): + return nil, req.Context().Err() + case <-time.After(200 * time.Millisecond): + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + } + }) + + c := rhttp.New(rhttp.WithTransport(rt)) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _, err := rhttp.R(c). + Context(ctx). + Get("http://example.com/api") + + if err == nil { + t.Fatal("expected context timeout error") + } +} + +func TestRequestBuilder_AllMethods(t *testing.T) { + methods := []struct { + name string + fn func(*rhttp.RequestBuilder, string) (*http.Response, error) + expect string + }{ + {"Get", func(rb *rhttp.RequestBuilder, url string) (*http.Response, error) { return rb.Get(url) }, "GET"}, + {"Post", func(rb *rhttp.RequestBuilder, url string) (*http.Response, error) { return rb.Post(url) }, "POST"}, + {"Put", func(rb *rhttp.RequestBuilder, url string) (*http.Response, error) { return rb.Put(url) }, "PUT"}, + {"Patch", func(rb *rhttp.RequestBuilder, url string) (*http.Response, error) { return rb.Patch(url) }, "PATCH"}, + {"Delete", func(rb *rhttp.RequestBuilder, url string) (*http.Response, error) { return rb.Delete(url) }, "DELETE"}, + {"Head", func(rb *rhttp.RequestBuilder, url string) (*http.Response, error) { return rb.Head(url) }, "HEAD"}, + {"Options", func(rb *rhttp.RequestBuilder, url string) (*http.Response, error) { return rb.Options(url) }, "OPTIONS"}, + } + + for _, m := range methods { + t.Run(m.name, func(t *testing.T) { + var capturedMethod string + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + capturedMethod = req.Method + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New(rhttp.WithTransport(rt)) + _, _ = m.fn(rhttp.R(c), "http://example.com") + + if capturedMethod != m.expect { + t.Errorf("expected %s, got %s", m.expect, capturedMethod) + } + }) + } +} + +func BenchmarkRequestBuilder_Simple(b *testing.B) { + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New(rhttp.WithTransport(rt)) + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _, _ = rhttp.R(c).Get("http://example.com") + } +} + +func BenchmarkRequestBuilder_WithOptions(b *testing.B) { + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New(rhttp.WithTransport(rt)) + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _, _ = rhttp.R(c). + SetHeader("Authorization", "Bearer token"). + SetQueryParam("page", "1"). + SetPathParam("id", "123"). + Get("http://example.com/users/{id}") + } +} diff --git a/retry.go b/retry.go new file mode 100644 index 0000000..d533ad6 --- /dev/null +++ b/retry.go @@ -0,0 +1,141 @@ +package rhttp + +import ( + "io" + "net/http" + "time" +) + +// RetryConfig configures the retry middleware. +type RetryConfig struct { + // MaxAttempts is the maximum number of attempts (including the first one). + MaxAttempts int + + // Backoff returns the duration to wait before the nth retry (0-indexed). + // If nil, exponential backoff is used. + Backoff func(attempt int) time.Duration + + // IsRetryable determines if a request should be retried based on the response and error. + // If nil, default retry logic is used. + IsRetryable func(resp *http.Response, err error) bool + + // RetryAllMethods if true, retries all HTTP methods including non-idempotent ones. + // Default is false (only retry idempotent methods). + RetryAllMethods bool +} + +// Retry returns a middleware that retries failed requests. +func Retry(cfg RetryConfig) Middleware { + if cfg.MaxAttempts <= 0 { + cfg.MaxAttempts = 3 + } + if cfg.Backoff == nil { + cfg.Backoff = ExponentialBackoff(100*time.Millisecond, 10*time.Second) + } + if cfg.IsRetryable == nil { + cfg.IsRetryable = DefaultIsRetryable + } + + return func(next http.RoundTripper) http.RoundTripper { + return retryRoundTripper{ + next: next, + cfg: cfg, + } + } +} + +type retryRoundTripper struct { + next http.RoundTripper + cfg RetryConfig +} + +func (r retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if !r.canRetry(req) { + return r.next.RoundTrip(req) + } + + var resp *http.Response + var err error + + for attempt := 0; attempt < r.cfg.MaxAttempts; attempt++ { + if attempt > 0 { + if err := r.prepareRetry(req, attempt); err != nil { + return nil, err + } + } + + resp, err = r.next.RoundTrip(req) + + if !r.cfg.IsRetryable(resp, err) { + return resp, err + } + + if attempt < r.cfg.MaxAttempts-1 { + drainAndClose(resp) + } + } + + return resp, err +} + +func (r retryRoundTripper) canRetry(req *http.Request) bool { + if !r.cfg.RetryAllMethods && !isIdempotent(req.Method) { + return false + } + + return req.Body == nil || req.Body == http.NoBody || req.GetBody != nil +} + +func (r retryRoundTripper) prepareRetry(req *http.Request, attempt int) error { + if req.GetBody != nil { + body, err := req.GetBody() + if err != nil { + return err + } + req.Body = body + } + + select { + case <-req.Context().Done(): + return req.Context().Err() + case <-time.After(r.cfg.Backoff(attempt - 1)): + return nil + } +} + +func drainAndClose(resp *http.Response) { + if resp == nil || resp.Body == nil { + return + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() +} + +// isIdempotent returns true for HTTP methods that are safe to retry. +func isIdempotent(method string) bool { + switch method { + case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodPut, http.MethodDelete: + return true + default: + return false + } +} + +// DefaultIsRetryable returns true for transient errors and retryable status codes. +func DefaultIsRetryable(resp *http.Response, err error) bool { + if err != nil { + return true + } + if resp == nil { + return false + } + switch resp.StatusCode { + case http.StatusTooManyRequests, + http.StatusBadGateway, + http.StatusServiceUnavailable, + http.StatusGatewayTimeout: + return true + default: + return false + } +} diff --git a/retry_test.go b/retry_test.go new file mode 100644 index 0000000..1265481 --- /dev/null +++ b/retry_test.go @@ -0,0 +1,360 @@ +package rhttp_test + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/oswaldom-code/rhttp" + "github.com/oswaldom-code/rhttp/internal" +) + +func TestRetry_SuccessOnFirstAttempt(t *testing.T) { + var attempts int32 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&attempts, 1) + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{MaxAttempts: 3})), + ) + + 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) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", resp.StatusCode) + } + if attempts != 1 { + t.Fatalf("expected 1 attempt, got %d", attempts) + } +} + +func TestRetry_SuccessAfterRetry(t *testing.T) { + var attempts int32 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + n := atomic.AddInt32(&attempts, 1) + if n < 3 { + return &http.Response{ + StatusCode: http.StatusServiceUnavailable, + Body: io.NopCloser(strings.NewReader("")), + Request: req, + }, nil + } + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + Backoff: func(int) time.Duration { return time.Millisecond }, + })), + ) + + 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) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if attempts != 3 { + t.Fatalf("expected 3 attempts, got %d", attempts) + } +} + +func TestRetry_MaxAttemptsExhausted(t *testing.T) { + var attempts int32 + expectedErr := errors.New("connection refused") + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&attempts, 1) + return nil, expectedErr + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + Backoff: func(int) time.Duration { return time.Millisecond }, + })), + ) + + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, err := c.Do(context.Background(), req) + + if err != expectedErr { + t.Fatalf("expected %v, got %v", expectedErr, err) + } + if attempts != 3 { + t.Fatalf("expected 3 attempts, got %d", attempts) + } +} + +func TestRetry_NonIdempotentMethodNotRetried(t *testing.T) { + var attempts int32 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&attempts, 1) + return nil, errors.New("connection refused") + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{MaxAttempts: 3})), + ) + + req, _ := http.NewRequest(http.MethodPost, "http://example.com", http.NoBody) + _, _ = c.Do(context.Background(), req) + + if attempts != 1 { + t.Fatalf("expected 1 attempt for POST, got %d", attempts) + } +} + +func TestRetry_NonIdempotentMethodWithRetryAllMethods(t *testing.T) { + var attempts int32 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + n := atomic.AddInt32(&attempts, 1) + if n < 2 { + return nil, errors.New("connection refused") + } + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + RetryAllMethods: true, + Backoff: func(int) time.Duration { return time.Millisecond }, + })), + ) + + body := bytes.NewReader([]byte("test")) + req, _ := http.NewRequest(http.MethodPost, "http://example.com", body) + req.GetBody = func() (io.ReadCloser, error) { + body.Seek(0, io.SeekStart) + return io.NopCloser(body), nil + } + + resp, err := c.Do(context.Background(), req) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if attempts != 2 { + t.Fatalf("expected 2 attempts, got %d", attempts) + } +} + +func TestRetry_ContextCancelledDuringBackoff(t *testing.T) { + var attempts int32 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&attempts, 1) + return nil, errors.New("connection refused") + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + Backoff: func(int) time.Duration { return 10 * time.Second }, + })), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, err := c.Do(ctx, req) + + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected DeadlineExceeded, got %v", err) + } + if attempts != 1 { + t.Fatalf("expected 1 attempt before context cancel, got %d", attempts) + } +} + +func TestRetry_RetryableStatusCodes(t *testing.T) { + retryableCodes := []int{ + http.StatusTooManyRequests, + http.StatusBadGateway, + http.StatusServiceUnavailable, + http.StatusGatewayTimeout, + } + + for _, code := range retryableCodes { + t.Run(http.StatusText(code), func(t *testing.T) { + var attempts int32 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + n := atomic.AddInt32(&attempts, 1) + if n < 2 { + return &http.Response{ + StatusCode: code, + Body: io.NopCloser(strings.NewReader("")), + Request: req, + }, nil + } + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + Backoff: func(int) time.Duration { return time.Millisecond }, + })), + ) + + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + resp, _ := c.Do(context.Background(), req) + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected retry to succeed, got status %d", resp.StatusCode) + } + if attempts != 2 { + t.Fatalf("expected 2 attempts, got %d", attempts) + } + }) + } +} + +func TestRetry_LastAttemptBodyReadable(t *testing.T) { + const payload = "final-503-body" + var attempts int32 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&attempts, 1) + return &http.Response{ + StatusCode: http.StatusServiceUnavailable, + Body: io.NopCloser(strings.NewReader(payload)), + Request: req, + }, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + Backoff: func(int) time.Duration { return time.Millisecond }, + })), + ) + + 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() + + if attempts != 3 { + t.Fatalf("expected 3 attempts, got %d", attempts) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("reading last-attempt body: %v", err) + } + if string(body) != payload { + t.Fatalf("expected body %q, got %q", payload, body) + } +} + +func TestRetry_NonRetryableStatusCode(t *testing.T) { + var attempts int32 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&attempts, 1) + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader("")), + Request: req, + }, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{MaxAttempts: 3})), + ) + + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + resp, _ := c.Do(context.Background(), req) + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } + if attempts != 1 { + t.Fatalf("expected 1 attempt for non-retryable status, got %d", attempts) + } +} + +// nonReplayableReader is a reader that cannot be rewound. +type nonReplayableReader struct { + r io.Reader +} + +func (n *nonReplayableReader) Read(p []byte) (int, error) { + return n.r.Read(p) +} + +func TestRetry_NonReplayableBodyNotRetried(t *testing.T) { + var attempts int32 + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&attempts, 1) + return nil, errors.New("connection refused") + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ + MaxAttempts: 3, + Backoff: func(int) time.Duration { return time.Millisecond }, + })), + ) + + // Custom reader that http.NewRequest won't recognize - GetBody will be nil + body := &nonReplayableReader{r: strings.NewReader("data")} + req, _ := http.NewRequest(http.MethodPut, "http://example.com", body) + // Explicitly clear GetBody to ensure it's not set + req.GetBody = nil + _, _ = c.Do(context.Background(), req) + + if attempts != 1 { + t.Fatalf("expected 1 attempt for non-replayable body, got %d", attempts) + } +} + +func TestExponentialBackoff(t *testing.T) { + backoff := rhttp.ExponentialBackoff(100*time.Millisecond, 1*time.Second) + + // Test exponential growth (with some tolerance for jitter) + for attempt := 0; attempt < 5; attempt++ { + d := backoff(attempt) + expected := 100 * time.Millisecond * (1 << attempt) + if expected > 1*time.Second { + expected = 1 * time.Second + } + + // Allow 25% tolerance for jitter + minExpected := time.Duration(float64(expected) * 0.75) + maxExpected := time.Duration(float64(expected) * 1.25) + + if d < minExpected || d > maxExpected { + t.Errorf("attempt %d: expected ~%v, got %v", attempt, expected, d) + } + } +} diff --git a/timeout.go b/timeout.go new file mode 100644 index 0000000..666e19a --- /dev/null +++ b/timeout.go @@ -0,0 +1,59 @@ +package rhttp + +import ( + "context" + "io" + "net/http" + "time" +) + +// Timeout returns a middleware that applies a timeout to requests. +// If the request's context already has a shorter deadline, it is respected. +func Timeout(d time.Duration) Middleware { + return func(next http.RoundTripper) http.RoundTripper { + return timeoutRoundTripper{next: next, timeout: d} + } +} + +type timeoutRoundTripper struct { + next http.RoundTripper + timeout time.Duration +} + +func (t timeoutRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + ctx := req.Context() + + // Only apply timeout if ctx doesn't have a shorter deadline + if deadline, ok := ctx.Deadline(); ok { + if time.Until(deadline) <= t.timeout { + return t.next.RoundTrip(req) + } + } + + ctx, cancel := context.WithTimeout(ctx, t.timeout) + + req = req.Clone(ctx) + resp, err := t.next.RoundTrip(req) + 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 +} + +type cancelBody struct { + io.ReadCloser + cancel context.CancelFunc +} + +func (b *cancelBody) Close() error { + err := b.ReadCloser.Close() + b.cancel() + return err +} diff --git a/timeout_test.go b/timeout_test.go new file mode 100644 index 0000000..d4d29e8 --- /dev/null +++ b/timeout_test.go @@ -0,0 +1,153 @@ +package rhttp_test + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/oswaldom-code/rhttp" + "github.com/oswaldom-code/rhttp/internal" +) + +func TestTimeout_RequestCompletesBeforeTimeout(t *testing.T) { + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Timeout(5*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) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", resp.StatusCode) + } +} + +func TestTimeout_RequestExceedsTimeout(t *testing.T) { + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + select { + case <-req.Context().Done(): + return nil, req.Context().Err() + case <-time.After(500 * time.Millisecond): + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + } + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Timeout(50*time.Millisecond)), + ) + + req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + _, err := c.Do(context.Background(), req) + + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected DeadlineExceeded, got: %v", err) + } +} + +func TestTimeout_RespectsExistingShorterDeadline(t *testing.T) { + var capturedDeadline time.Time + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + capturedDeadline, _ = req.Context().Deadline() + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Timeout(10*time.Second)), + ) + + // Context with 100ms deadline (shorter than middleware's 10s) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.com", http.NoBody) + expectedDeadline, _ := ctx.Deadline() + + _, _ = c.Do(ctx, req) + + // The captured deadline should match the original context's deadline + if !capturedDeadline.Equal(expectedDeadline) { + t.Fatalf("expected deadline %v, got %v", expectedDeadline, capturedDeadline) + } +} + +func TestTimeout_StreamingBodyReadableAfterReturn(t *testing.T) { + const head, tail = "first-chunk-", "second-chunk" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fl, ok := w.(http.Flusher) + if !ok { + t.Error("ResponseWriter is not a Flusher") + return + } + _, _ = io.WriteString(w, head) + fl.Flush() + time.Sleep(50 * time.Millisecond) + _, _ = io.WriteString(w, tail) + })) + defer srv.Close() + + c := rhttp.New( + rhttp.WithMiddleware(rhttp.Timeout(5 * time.Second)), + ) + + req, _ := http.NewRequest(http.MethodGet, srv.URL, 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 streamed body after RoundTrip returned: %v", err) + } + if string(body) != head+tail { + t.Fatalf("expected body %q, got %q", head+tail, body) + } +} + +func TestTimeout_AppliesWhenExistingDeadlineLonger(t *testing.T) { + var capturedCtx context.Context + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + capturedCtx = req.Context() + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Timeout(100*time.Millisecond)), + ) + + // Context with 10s deadline (longer than middleware's 100ms) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.com", http.NoBody) + + start := time.Now() + _, _ = c.Do(ctx, req) + + deadline, ok := capturedCtx.Deadline() + if !ok { + t.Fatal("expected deadline to be set") + } + + // Deadline should be ~100ms from start, not 10s + timeUntilDeadline := time.Until(deadline) + if timeUntilDeadline > 150*time.Millisecond { + t.Fatalf("expected deadline ~100ms from now, got %v (started at %v)", deadline, start) + } +} diff --git a/transport.go b/transport.go new file mode 100644 index 0000000..fa6b627 --- /dev/null +++ b/transport.go @@ -0,0 +1,19 @@ +package rhttp + +import ( + "net/http" + "time" +) + +// DefaultTransport returns a production-ready http.Transport. +// No global state, explicit configuration. +func DefaultTransport() *http.Transport { + return &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + ForceAttemptHTTP2: true, + } +}