From d4631eb128ae0e4dcce0ab5d600cc782cd011b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Mon, 19 Jan 2026 14:16:20 +0100 Subject: [PATCH 01/13] Add initial project setup with README, Makefile, and configuration files --- .gitignore | 29 ++++ .golangci.yml | 151 +++++++++++++++++ Makefile | 136 +++++++++++++++ README.md | 448 +++++++++++++++++++++++++++++++++++++++++++++++++- go.mod | 3 + 5 files changed, 766 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 Makefile create mode 100644 go.mod 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..9d18d42 --- /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 + - commentedOutCod + + gofmt: + simplify: true + + goimports: + local-prefixes: github.com/oswaldom-code/go-httpclient + + 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/Makefile b/Makefile new file mode 100644 index 0000000..b22c29b --- /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 "go-httpclient - 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/go-httpclient/httpclient" + @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..cb8e926 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,448 @@ # go-httpclient -HTTP client for Go. + +Production-grade HTTP client for Go with built-in resiliency patterns. + +[![CI](https://github.com/oswaldom-code/go-httpclient/actions/workflows/ci.yml/badge.svg)](https://github.com/oswaldom-code/go-httpclient/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/oswaldom-code/go-httpclient/branch/main/graph/badge.svg)](https://codecov.io/gh/oswaldom-code/go-httpclient) +[![Go Report Card](https://goreportcard.com/badge/github.com/oswaldom-code/go-httpclient)](https://goreportcard.com/report/github.com/oswaldom-code/go-httpclient) +[![Go Reference](https://pkg.go.dev/badge/github.com/oswaldom-code/go-httpclient.svg)](https://pkg.go.dev/github.com/oswaldom-code/go-httpclient) +[![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/httpclient` | 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/go-httpclient +``` + +Requires Go 1.21+ + +## Quick Start + +### Basic Usage + +```go +package main + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/oswaldom-code/go-httpclient/httpclient" +) + +func main() { + // Create client with middleware + client := httpclient.New( + httpclient.WithMiddleware( + httpclient.Timeout(5*time.Second), + httpclient.Retry(httpclient.RetryConfig{MaxAttempts: 3}), + httpclient.CircuitBreaker(httpclient.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 := httpclient.New() + +// GET request with query params +resp, err := httpclient.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 := httpclient.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 := httpclient.R(client). + SetPathParam("org", "acme"). + SetPathParam("repo", "api"). + Get("https://api.github.com/repos/{org}/{repo}") +``` + +## Middleware + +### Timeout + +```go +client := httpclient.New( + httpclient.WithMiddleware( + httpclient.Timeout(5*time.Second), + ), +) +``` + +Respects existing context deadlines - uses the shorter of the two. + +### Retry + +```go +client := httpclient.New( + httpclient.WithMiddleware( + httpclient.Retry(httpclient.RetryConfig{ + MaxAttempts: 3, + Backoff: httpclient.ExponentialBackoff(100*time.Millisecond, 10*time.Second), + IsRetryable: httpclient.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 := httpclient.New( + httpclient.WithMiddleware( + httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + FailureThreshold: 5, // Open after 5 consecutive failures + ResetTimeout: 30*time.Second, // Try half-open after 30s + IsFailure: httpclient.DefaultIsFailure, // Errors + 5xx + }), + ), +) +``` + +State machine: `Closed → Open → Half-Open → Closed/Open` + +Returns `httpclient.ErrCircuitOpen` when circuit is open. + +### Rate Limiting + +```go +// Token bucket: 100 requests/second, burst of 10 +limiter := httpclient.NewTokenBucket(100, 10) + +client := httpclient.New( + httpclient.WithMiddleware( + httpclient.RateLimit(httpclient.RateLimitConfig{ + Limiter: limiter, + WaitOnLimit: true, // Block until token available + RespectRetryAfter: true, // Honor Retry-After header + }), + ), +) + +// Per-host rate limiting +perHostLimiter := httpclient.NewPerHostRateLimiter(50, 5) // 50 req/s per host +``` + +### Logging + +```go +client := httpclient.New( + httpclient.WithMiddleware( + httpclient.Logging(httpclient.LoggingConfig{ + Logger: httpclient.LoggerFunc(func(e httpclient.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 := httpclient.New( + httpclient.WithMiddleware( + httpclient.Metrics(httpclient.MetricsConfig{ + Recorder: httpclient.MetricsRecorderFunc(func(e httpclient.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 := httpclient.Classify(err) + + switch classified.Kind { + case httpclient.ErrKindTimeout: + // Request timed out + case httpclient.ErrKindCancelled: + // Context was cancelled + case httpclient.ErrKindConnection: + // Connection refused, reset, etc. + case httpclient.ErrKindDNS: + // DNS resolution failed + case httpclient.ErrKindTLS: + // Certificate error + case httpclient.ErrKindTemporary: + // Temporary error, may resolve on retry + } + + // Or use helpers + if httpclient.IsRetryable(err) { + // Safe to retry (timeout, connection, DNS, temporary) + } +} +``` + +## Middleware Order + +Middleware executes in the order specified: + +```go +client := httpclient.New( + httpclient.WithMiddleware( + httpclient.Logging(...), // 1. Log request start + httpclient.Metrics(...), // 2. Start timing + httpclient.Timeout(...), // 3. Apply timeout + httpclient.RateLimit(...), // 4. Check rate limit + httpclient.CircuitBreaker(...), // 5. Check circuit + httpclient.Retry(...), // 6. Retry on failure + ), +) +``` + +Recommended order: `Logging → Metrics → Timeout → RateLimit → CircuitBreaker → Retry` + +## Custom Transport + +```go +// Use custom transport +client := httpclient.New( + httpclient.WithTransport(&http.Transport{ + MaxIdleConns: 200, + MaxIdleConnsPerHost: 20, + IdleConnTimeout: 90*time.Second, + }), +) + +// Or use optimized default +transport := httpclient.DefaultTransport() // HTTP/2 enabled, optimized pool +``` + +## Object Pooling + +Reduce allocations with buffer pooling: + +```go +// Get a buffer from the pool +buf := httpclient.GetBuffer() +defer httpclient.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/go-httpclient/httpclient) 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 + +### 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/go.mod b/go.mod new file mode 100644 index 0000000..542ad99 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/oswaldom-code/go-httpclient + +go 1.24.0 From 14b91dd8ec6ce1356200b879f547751c25ab5212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Mon, 19 Jan 2026 15:18:05 +0100 Subject: [PATCH 02/13] Add CI configuration for testing, linting, building, and benchmarking --- .github/workflows/ci.yml | 100 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b12dada --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +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 + + - 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 + + - 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 + + - name: Build + run: go build ./... + + - name: Verify go.mod is tidy + run: | + go mod tidy + git diff --exit-code go.mod go.sum + + 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 + + - 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 From 8db96c74b045ab09300f91d60550f6399400b11d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Wed, 1 Jul 2026 21:50:48 +0200 Subject: [PATCH 03/13] Fix: CI cache dependency paths and correct golangci configuration typo - Add `cache-dependency-path: go.mod` to all `setup-go` steps in the CI workflow to ensure proper caching. - Fix a typo in `.golangci.yml` for the gocritic `commentedOutCode` disabled check. --- .github/workflows/ci.yml | 4 ++++ .golangci.yml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b12dada..e569179 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,7 @@ jobs: with: go-version: ${{ matrix.go-version }} cache: true + cache-dependency-path: go.mod - name: Download dependencies run: go mod download @@ -48,6 +49,7 @@ jobs: with: go-version: '1.23' cache: true + cache-dependency-path: go.mod - name: Run golangci-lint uses: golangci/golangci-lint-action@v6 @@ -67,6 +69,7 @@ jobs: with: go-version: '1.23' cache: true + cache-dependency-path: go.mod - name: Build run: go build ./... @@ -89,6 +92,7 @@ jobs: with: go-version: '1.23' cache: true + cache-dependency-path: go.mod - name: Run benchmarks run: go test -bench=. -benchmem ./... | tee benchmark.txt diff --git a/.golangci.yml b/.golangci.yml index 9d18d42..bace925 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -54,7 +54,7 @@ linters-settings: disabled-checks: - hugeParam - whyNoLint - - commentedOutCod + - commentedOutCode gofmt: simplify: true From 4636c33147c5fa25de31a68c68a300d22f89a67e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Wed, 1 Jul 2026 21:55:43 +0200 Subject: [PATCH 04/13] chore: Update CI to remove go.sum from go mod tidy verification - Remove `go.sum` from the `git diff --exit-code` command in the `.github/workflows/ci.yml` build job to only verify `go.mod`. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e569179..57312e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,7 @@ jobs: - name: Verify go.mod is tidy run: | go mod tidy - git diff --exit-code go.mod go.sum + git diff --exit-code go.mod benchmark: name: Benchmark From 3d12700d3cd770b396e2b2ac09cad0dade632c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Wed, 1 Jul 2026 22:03:05 +0200 Subject: [PATCH 05/13] chore: Disable CI workflow by commenting out configuration - Comment out the entire `.github/workflows/ci.yml` file to temporarily disable the GitHub Actions CI pipeline. --- .github/workflows/ci.yml | 208 +++++++++++++++++++-------------------- 1 file changed, 104 insertions(+), 104 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57312e9..3b3d349 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,104 +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 +#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 From 116fb331a1b1a89a2d072c3ae0bdc132994e199a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Wed, 1 Jul 2026 22:26:20 +0200 Subject: [PATCH 06/13] feat(httpclient): add Phase 1 foundation package Core HTTP client with middleware chain and resiliency patterns: client, options, transport, middleware chain, timeout, retry with backoff strategies, circuit breaker, rate limiting, logging, metrics, error classification, fluent RequestBuilder and object pooling. Includes unit tests, examples and benchmarks. Zero dependencies. --- httpclient/backoff.go | 170 +++++++++++++ httpclient/backoff_test.go | 222 ++++++++++++++++ httpclient/benchmark_test.go | 205 +++++++++++++++ httpclient/circuitbreaker.go | 146 +++++++++++ httpclient/circuitbreaker_test.go | 363 ++++++++++++++++++++++++++ httpclient/client.go | 44 ++++ httpclient/client_test.go | 76 ++++++ httpclient/doc.go | 83 ++++++ httpclient/errorclass.go | 231 +++++++++++++++++ httpclient/errorclass_test.go | 268 +++++++++++++++++++ httpclient/errors.go | 14 + httpclient/example_test.go | 198 +++++++++++++++ httpclient/internal/roundtripper.go | 13 + httpclient/logging.go | 96 +++++++ httpclient/logging_test.go | 211 +++++++++++++++ httpclient/metrics.go | 96 +++++++ httpclient/metrics_test.go | 267 +++++++++++++++++++ httpclient/middleware.go | 16 ++ httpclient/options.go | 33 +++ httpclient/pool.go | 97 +++++++ httpclient/pool_test.go | 148 +++++++++++ httpclient/ratelimit.go | 231 +++++++++++++++++ httpclient/ratelimit_test.go | 281 ++++++++++++++++++++ httpclient/request.go | 319 +++++++++++++++++++++++ httpclient/request_test.go | 381 ++++++++++++++++++++++++++++ httpclient/retry.go | 128 ++++++++++ httpclient/retry_test.go | 321 +++++++++++++++++++++++ httpclient/timeout.go | 37 +++ httpclient/timeout_test.go | 116 +++++++++ httpclient/transport.go | 19 ++ 30 files changed, 4830 insertions(+) create mode 100644 httpclient/backoff.go create mode 100644 httpclient/backoff_test.go create mode 100644 httpclient/benchmark_test.go create mode 100644 httpclient/circuitbreaker.go create mode 100644 httpclient/circuitbreaker_test.go create mode 100644 httpclient/client.go create mode 100644 httpclient/client_test.go create mode 100644 httpclient/doc.go create mode 100644 httpclient/errorclass.go create mode 100644 httpclient/errorclass_test.go create mode 100644 httpclient/errors.go create mode 100644 httpclient/example_test.go create mode 100644 httpclient/internal/roundtripper.go create mode 100644 httpclient/logging.go create mode 100644 httpclient/logging_test.go create mode 100644 httpclient/metrics.go create mode 100644 httpclient/metrics_test.go create mode 100644 httpclient/middleware.go create mode 100644 httpclient/options.go create mode 100644 httpclient/pool.go create mode 100644 httpclient/pool_test.go create mode 100644 httpclient/ratelimit.go create mode 100644 httpclient/ratelimit_test.go create mode 100644 httpclient/request.go create mode 100644 httpclient/request_test.go create mode 100644 httpclient/retry.go create mode 100644 httpclient/retry_test.go create mode 100644 httpclient/timeout.go create mode 100644 httpclient/timeout_test.go create mode 100644 httpclient/transport.go diff --git a/httpclient/backoff.go b/httpclient/backoff.go new file mode 100644 index 0000000..414e4a3 --- /dev/null +++ b/httpclient/backoff.go @@ -0,0 +1,170 @@ +package httpclient + +import ( + "math/rand/v2" + "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) + if backoff > maxDuration { + 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 + + if backoff > maxDuration { + 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) + if ceiling > maxDuration { + 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) + if ceiling > maxDuration { + 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/httpclient/backoff_test.go b/httpclient/backoff_test.go new file mode 100644 index 0000000..b16479c --- /dev/null +++ b/httpclient/backoff_test.go @@ -0,0 +1,222 @@ +package httpclient_test + +import ( + "testing" + "time" + + "github.com/oswaldom-code/go-httpclient/httpclient" +) + +func TestConstantBackoff(t *testing.T) { + backoff := httpclient.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 := httpclient.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 := httpclient.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 := httpclient.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 := httpclient.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 := httpclient.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 := httpclient.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 := httpclient.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 := httpclient.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 := httpclient.ConstantBackoff(100 * time.Millisecond) + withJitter := httpclient.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 := httpclient.LinearBackoff(100*time.Millisecond, 10*time.Second) + capped := httpclient.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 := httpclient.ConstantBackoff(10 * time.Millisecond) + withMin := httpclient.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]httpclient.BackoffFunc{ + "Constant": httpclient.ConstantBackoff(100 * time.Millisecond), + "Linear": httpclient.LinearBackoff(100*time.Millisecond, 10*time.Second), + "Exponential": httpclient.ExponentialBackoff(100*time.Millisecond, 10*time.Second), + "Fibonacci": httpclient.FibonacciBackoff(100*time.Millisecond, 10*time.Second), + "FullJitter": httpclient.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/httpclient/benchmark_test.go b/httpclient/benchmark_test.go new file mode 100644 index 0000000..44c06cf --- /dev/null +++ b/httpclient/benchmark_test.go @@ -0,0 +1,205 @@ +package httpclient_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/oswaldom-code/go-httpclient/httpclient" + "github.com/oswaldom-code/go-httpclient/httpclient/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 = httpclient.LoggerFunc(func(httpclient.LogEntry) {}) + +// noopRecorder discards all metric events +var noopRecorder = httpclient.MetricsRecorderFunc(func(httpclient.MetricEvent) {}) + +func BenchmarkClient_Baseline(b *testing.B) { + c := httpclient.New(httpclient.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 := httpclient.New( + httpclient.WithTransport(noopRoundTripper), + httpclient.WithMiddleware(httpclient.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 := httpclient.New( + httpclient.WithTransport(noopRoundTripper), + httpclient.WithMiddleware(httpclient.Retry(httpclient.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 := httpclient.New( + httpclient.WithTransport(noopRoundTripper), + httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.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 := httpclient.New( + httpclient.WithTransport(noopRoundTripper), + httpclient.WithMiddleware(httpclient.Logging(httpclient.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 := httpclient.New( + httpclient.WithTransport(noopRoundTripper), + httpclient.WithMiddleware(httpclient.Metrics(httpclient.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 := httpclient.New( + httpclient.WithTransport(noopRoundTripper), + httpclient.WithMiddleware( + httpclient.Timeout(5*time.Second), + httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + FailureThreshold: 5, + ResetTimeout: 30 * time.Second, + }), + httpclient.Retry(httpclient.RetryConfig{MaxAttempts: 3}), + httpclient.Logging(httpclient.LoggingConfig{Logger: noopLogger}), + httpclient.Metrics(httpclient.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 := httpclient.New( + httpclient.WithTransport(noopRoundTripper), + httpclient.WithMiddleware( + httpclient.Timeout(5*time.Second), + httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + FailureThreshold: 5, + ResetTimeout: 30 * time.Second, + }), + httpclient.Retry(httpclient.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++ { + _ = httpclient.Classify(err) + } +} diff --git a/httpclient/circuitbreaker.go b/httpclient/circuitbreaker.go new file mode 100644 index 0000000..c3426f3 --- /dev/null +++ b/httpclient/circuitbreaker.go @@ -0,0 +1,146 @@ +package httpclient + +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 +} + +// 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 + } + + 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 +} + +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 +} + +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 + return true + } + return false + + case CircuitHalfOpen: + // In half-open state, allow the request (only one at a time due to mutex) + return true + + default: + return true + } +} + +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: + if isFailure { + cb.failures++ + cb.lastFailureTime = time.Now() + if cb.failures >= cb.cfg.FailureThreshold { + cb.state = CircuitOpen + } + } else { + cb.failures = 0 + } + + case CircuitHalfOpen: + if isFailure { + cb.state = CircuitOpen + cb.lastFailureTime = time.Now() + cb.failures = cb.cfg.FailureThreshold + } else { + cb.state = CircuitClosed + cb.failures = 0 + } + } +} + +// 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 +} + +func DefaultIsFailure(resp *http.Response, err error) bool { + if err != nil { + return true + } + if resp != nil && resp.StatusCode >= 500 { + return true + } + return false +} diff --git a/httpclient/circuitbreaker_test.go b/httpclient/circuitbreaker_test.go new file mode 100644 index 0000000..2b3623b --- /dev/null +++ b/httpclient/circuitbreaker_test.go @@ -0,0 +1,363 @@ +package httpclient_test + +import ( + "context" + "errors" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/oswaldom-code/go-httpclient/httpclient" + "github.com/oswaldom-code/go-httpclient/httpclient/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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.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, httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.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, httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.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, httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.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, httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.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, httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.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) + } +} + +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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.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, httpclient.ErrCircuitOpen) { + t.Fatalf("expected circuit to open with custom IsFailure, got %v", err) + } +} diff --git a/httpclient/client.go b/httpclient/client.go new file mode 100644 index 0000000..6e2a3f1 --- /dev/null +++ b/httpclient/client.go @@ -0,0 +1,44 @@ +package httpclient + +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/httpclient/client_test.go b/httpclient/client_test.go new file mode 100644 index 0000000..a77d4a9 --- /dev/null +++ b/httpclient/client_test.go @@ -0,0 +1,76 @@ +package httpclient_test + +import ( + "context" + "net/http" + "testing" + + "github.com/oswaldom-code/go-httpclient/httpclient" + "github.com/oswaldom-code/go-httpclient/httpclient/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 := httpclient.New(httpclient.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 := httpclient.New() + + _, err := c.Do(context.Background(), nil) + if err != httpclient.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 := httpclient.New( + httpclient.WithTransport(base), + httpclient.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/httpclient/doc.go b/httpclient/doc.go new file mode 100644 index 0000000..c975bc4 --- /dev/null +++ b/httpclient/doc.go @@ -0,0 +1,83 @@ +// Package httpclient 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 := httpclient.New() +// resp, err := client.Do(ctx, req) +// +// Create a client with middleware: +// +// client := httpclient.New( +// httpclient.WithMiddleware( +// httpclient.Timeout(5*time.Second), +// httpclient.Retry(httpclient.RetryConfig{MaxAttempts: 3}), +// httpclient.CircuitBreaker(httpclient.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 := httpclient.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 := httpclient.Classify(err) +// if classified.Kind == httpclient.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 httpclient diff --git a/httpclient/errorclass.go b/httpclient/errorclass.go new file mode 100644 index 0000000..17aa805 --- /dev/null +++ b/httpclient/errorclass.go @@ -0,0 +1,231 @@ +package httpclient + +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/httpclient/errorclass_test.go b/httpclient/errorclass_test.go new file mode 100644 index 0000000..7c87f6a --- /dev/null +++ b/httpclient/errorclass_test.go @@ -0,0 +1,268 @@ +package httpclient_test + +import ( + "context" + "errors" + "net" + "net/url" + "testing" + + "github.com/oswaldom-code/go-httpclient/httpclient" +) + +func TestClassify_DeadlineExceeded(t *testing.T) { + classified := httpclient.Classify(context.DeadlineExceeded) + + if classified.Kind != httpclient.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 := httpclient.Classify(context.Canceled) + + if classified.Kind != httpclient.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 := httpclient.Classify(dnsErr) + + if classified.Kind != httpclient.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 := httpclient.Classify(err) + + if classified.Kind != httpclient.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 := httpclient.Classify(err) + + if classified.Kind != httpclient.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 := httpclient.Classify(err) + + if classified.Kind != httpclient.ErrKindTLS { + t.Errorf("expected ErrKindTLS, got %v", classified.Kind) + } +} + +func TestClassify_X509Error(t *testing.T) { + err := errors.New("x509: certificate has expired") + classified := httpclient.Classify(err) + + if classified.Kind != httpclient.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 := httpclient.Classify(urlErr) + + if classified.Kind != httpclient.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 := httpclient.Classify(urlErr) + + if classified.Kind != httpclient.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 := httpclient.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 := httpclient.Classify(err) + + if classified.Kind != httpclient.ErrKindUnknown { + t.Errorf("expected ErrKindUnknown, got %v", classified.Kind) + } +} + +func TestClassifiedError_Error(t *testing.T) { + err := errors.New("connection refused") + classified := httpclient.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 := httpclient.Classify(originalErr) + + if !errors.Is(classified, originalErr) { + t.Error("errors.Is should match original error") + } +} + +func TestErrorKind_String(t *testing.T) { + tests := []struct { + kind httpclient.ErrorKind + expected string + }{ + {httpclient.ErrKindTimeout, "timeout"}, + {httpclient.ErrKindCanceled, "canceled"}, + {httpclient.ErrKindConnection, "connection"}, + {httpclient.ErrKindDNS, "dns"}, + {httpclient.ErrKindTLS, "tls"}, + {httpclient.ErrKindTemporary, "temporary"}, + {httpclient.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 := []httpclient.ErrorKind{ + httpclient.ErrKindTimeout, + httpclient.ErrKindConnection, + httpclient.ErrKindDNS, + httpclient.ErrKindTemporary, + } + for _, k := range retryable { + if !k.IsRetryable() { + t.Errorf("expected %v to be retryable", k) + } + } + + notRetryable := []httpclient.ErrorKind{ + httpclient.ErrKindCanceled, + httpclient.ErrKindTLS, + httpclient.ErrKindUnknown, + } + for _, k := range notRetryable { + if k.IsRetryable() { + t.Errorf("expected %v to not be retryable", k) + } + } +} + +func TestIsTimeout(t *testing.T) { + if !httpclient.IsTimeout(context.DeadlineExceeded) { + t.Error("expected IsTimeout to be true for DeadlineExceeded") + } + if httpclient.IsTimeout(context.Canceled) { + t.Error("expected IsTimeout to be false for Canceled") + } + if httpclient.IsTimeout(nil) { + t.Error("expected IsTimeout to be false for nil") + } +} + +func TestIsCanceled(t *testing.T) { + if !httpclient.IsCanceled(context.Canceled) { + t.Error("expected IsCanceled to be true for Canceled") + } + if httpclient.IsCanceled(context.DeadlineExceeded) { + t.Error("expected IsCanceled to be false for DeadlineExceeded") + } + if httpclient.IsCanceled(nil) { + t.Error("expected IsCanceled to be false for nil") + } +} + +func TestIsConnection(t *testing.T) { + err := errors.New("connection refused") + if !httpclient.IsConnection(err) { + t.Error("expected IsConnection to be true for connection refused") + } + if httpclient.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 !httpclient.IsDNS(dnsErr) { + t.Error("expected IsDNS to be true for DNSError") + } + if httpclient.IsDNS(context.Canceled) { + t.Error("expected IsDNS to be false for Canceled") + } +} + +func TestIsTLS(t *testing.T) { + err := errors.New("tls: handshake failure") + if !httpclient.IsTLS(err) { + t.Error("expected IsTLS to be true for TLS error") + } + if httpclient.IsTLS(context.Canceled) { + t.Error("expected IsTLS to be false for Canceled") + } +} + +func TestIsRetryable(t *testing.T) { + // Retryable + if !httpclient.IsRetryable(context.DeadlineExceeded) { + t.Error("expected timeout to be retryable") + } + if !httpclient.IsRetryable(errors.New("connection refused")) { + t.Error("expected connection error to be retryable") + } + + // Not retryable + if httpclient.IsRetryable(context.Canceled) { + t.Error("expected canceled to not be retryable") + } + if httpclient.IsRetryable(errors.New("tls: certificate error")) { + t.Error("expected TLS error to not be retryable") + } + if httpclient.IsRetryable(nil) { + t.Error("expected nil to not be retryable") + } +} diff --git a/httpclient/errors.go b/httpclient/errors.go new file mode 100644 index 0000000..b1f3450 --- /dev/null +++ b/httpclient/errors.go @@ -0,0 +1,14 @@ +package httpclient + +import "errors" + +var ( + // ErrInvalidRequest is returned when a nil request is passed to Do. + ErrInvalidRequest = errors.New("httpclient: invalid request") + + // ErrCircuitOpen is returned when the circuit breaker is open. + ErrCircuitOpen = errors.New("httpclient: circuit breaker is open") + + // ErrRateLimited is returned when the rate limit is exceeded and WaitOnLimit is false. + ErrRateLimited = errors.New("httpclient: rate limit exceeded") +) diff --git a/httpclient/example_test.go b/httpclient/example_test.go new file mode 100644 index 0000000..4406e08 --- /dev/null +++ b/httpclient/example_test.go @@ -0,0 +1,198 @@ +package httpclient_test + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/oswaldom-code/go-httpclient/httpclient" +) + +func ExampleNew() { + // Create a basic client with default settings + client := httpclient.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 := httpclient.New( + httpclient.WithMiddleware( + httpclient.Timeout(5*time.Second), + httpclient.Retry(httpclient.RetryConfig{ + MaxAttempts: 3, + Backoff: httpclient.ExponentialBackoff(100*time.Millisecond, 5*time.Second), + }), + httpclient.CircuitBreaker(httpclient.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 := httpclient.New() + + // Use the fluent API to build and execute requests + resp, err := httpclient.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 := httpclient.New() + + type User struct { + Name string `json:"name"` + Email string `json:"email"` + } + + user := User{Name: "John", Email: "john@example.com"} + + resp, err := httpclient.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 := httpclient.New() + + // Path parameters are replaced in the URL template + resp, err := httpclient.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 := httpclient.New( + httpclient.WithMiddleware( + httpclient.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 := httpclient.Classify(err) + fmt.Printf("Error kind: %s, Retryable: %v\n", + classified.Kind, classified.Kind.IsRetryable()) + } +} + +func ExampleExponentialBackoff() { + backoff := httpclient.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 := httpclient.NewTokenBucket(10, 5) + + // Use with rate limit middleware + client := httpclient.New( + httpclient.WithMiddleware( + httpclient.RateLimit(httpclient.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 := httpclient.New( + httpclient.WithMiddleware( + httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + FailureThreshold: 5, + ResetTimeout: 30 * time.Second, + }), + ), + ) + + _ = client // Use client for requests +} + +func ExampleLogging() { + // Custom logger that prints request/response details + logger := httpclient.LoggerFunc(func(entry httpclient.LogEntry) { + fmt.Printf("%s %s -> %d (%s)\n", + entry.Method, entry.URL, entry.StatusCode, entry.Duration) + }) + + client := httpclient.New( + httpclient.WithMiddleware( + httpclient.Logging(httpclient.LoggingConfig{ + Logger: logger, + }), + ), + ) + + _ = client // Use client for requests +} + +func ExampleGetBuffer() { + // Get a buffer from the pool + buf := httpclient.GetBuffer() + + // Use the buffer + buf.WriteString("Hello, World!") + + // Return to pool when done + httpclient.PutBuffer(buf) +} diff --git a/httpclient/internal/roundtripper.go b/httpclient/internal/roundtripper.go new file mode 100644 index 0000000..4ebed52 --- /dev/null +++ b/httpclient/internal/roundtripper.go @@ -0,0 +1,13 @@ +// Package internal provides internal utilities for the httpclient 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/httpclient/logging.go b/httpclient/logging.go new file mode 100644 index 0000000..52faf59 --- /dev/null +++ b/httpclient/logging.go @@ -0,0 +1,96 @@ +package httpclient + +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/httpclient/logging_test.go b/httpclient/logging_test.go new file mode 100644 index 0000000..c112f1b --- /dev/null +++ b/httpclient/logging_test.go @@ -0,0 +1,211 @@ +package httpclient_test + +import ( + "context" + "errors" + "net/http" + "sync" + "testing" + "time" + + "github.com/oswaldom-code/go-httpclient/httpclient" + "github.com/oswaldom-code/go-httpclient/httpclient/internal" +) + +func TestLogging_LogsSuccessfulRequest(t *testing.T) { + var captured httpclient.LogEntry + logger := httpclient.LoggerFunc(func(entry httpclient.LogEntry) { + captured = entry + }) + + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Logging(httpclient.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 httpclient.LogEntry + logger := httpclient.LoggerFunc(func(entry httpclient.LogEntry) { + captured = entry + }) + + expectedErr := errors.New("connection refused") + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return nil, expectedErr + }) + + c := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Logging(httpclient.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 httpclient.LogEntry + logger := httpclient.LoggerFunc(func(entry httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Logging(httpclient.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 := httpclient.LoggerFunc(func(entry httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Logging(httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Logging(httpclient.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 []httpclient.LogEntry + logger := httpclient.LoggerFunc(func(entry httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Logging(httpclient.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/httpclient/metrics.go b/httpclient/metrics.go new file mode 100644 index 0000000..9a1dcd8 --- /dev/null +++ b/httpclient/metrics.go @@ -0,0 +1,96 @@ +package httpclient + +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/httpclient/metrics_test.go b/httpclient/metrics_test.go new file mode 100644 index 0000000..aeef2c1 --- /dev/null +++ b/httpclient/metrics_test.go @@ -0,0 +1,267 @@ +package httpclient_test + +import ( + "bytes" + "context" + "errors" + "net/http" + "sync" + "testing" + "time" + + "github.com/oswaldom-code/go-httpclient/httpclient" + "github.com/oswaldom-code/go-httpclient/httpclient/internal" +) + +func TestMetrics_RecordsSuccessfulRequest(t *testing.T) { + var captured httpclient.MetricEvent + recorder := httpclient.MetricsRecorderFunc(func(event httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Metrics(httpclient.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 httpclient.MetricEvent + recorder := httpclient.MetricsRecorderFunc(func(event httpclient.MetricEvent) { + captured = event + }) + + expectedErr := errors.New("connection refused") + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return nil, expectedErr + }) + + c := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Metrics(httpclient.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 httpclient.MetricEvent + recorder := httpclient.MetricsRecorderFunc(func(event httpclient.MetricEvent) { + captured = event + }) + + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusInternalServerError, Request: req}, nil + }) + + c := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Metrics(httpclient.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 httpclient.MetricEvent + recorder := httpclient.MetricsRecorderFunc(func(event httpclient.MetricEvent) { + captured = event + }) + + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusNotFound, Request: req}, nil + }) + + c := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Metrics(httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Metrics(httpclient.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 httpclient.MetricEvent + recorder := httpclient.MetricsRecorderFunc(func(event httpclient.MetricEvent) { + captured = event + }) + + rt := internal.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil + }) + + c := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Metrics(httpclient.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 httpclient.MetricEvent + recorder := httpclient.MetricsRecorderFunc(func(event httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Metrics(httpclient.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 []httpclient.MetricEvent + recorder := httpclient.MetricsRecorderFunc(func(event httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Metrics(httpclient.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/httpclient/middleware.go b/httpclient/middleware.go new file mode 100644 index 0000000..98f47c6 --- /dev/null +++ b/httpclient/middleware.go @@ -0,0 +1,16 @@ +package httpclient + +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/httpclient/options.go b/httpclient/options.go new file mode 100644 index 0000000..8cefbf9 --- /dev/null +++ b/httpclient/options.go @@ -0,0 +1,33 @@ +package httpclient + +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/httpclient/pool.go b/httpclient/pool.go new file mode 100644 index 0000000..1f74682 --- /dev/null +++ b/httpclient/pool.go @@ -0,0 +1,97 @@ +package httpclient + +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/httpclient/pool_test.go b/httpclient/pool_test.go new file mode 100644 index 0000000..6f6fb0d --- /dev/null +++ b/httpclient/pool_test.go @@ -0,0 +1,148 @@ +package httpclient_test + +import ( + "sync" + "testing" + + "github.com/oswaldom-code/go-httpclient/httpclient" +) + +func TestBufferPool_GetAndPut(t *testing.T) { + buf := httpclient.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()) + } + + httpclient.PutBuffer(buf) + + // Get another buffer - should be reset + buf2 := httpclient.GetBuffer() + if buf2.Len() != 0 { + t.Errorf("expected reset buffer with length 0, got %d", buf2.Len()) + } + httpclient.PutBuffer(buf2) +} + +func TestBufferPool_NilSafe(_ *testing.T) { + // Should not panic + httpclient.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 := httpclient.GetBuffer() + buf.WriteString("concurrent test") + httpclient.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 := &httpclient.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 := &httpclient.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 := &httpclient.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 := &httpclient.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 := httpclient.GetBuffer() + buf.WriteString("benchmark test data") + httpclient.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/httpclient/ratelimit.go b/httpclient/ratelimit.go new file mode 100644 index 0000000..1e97083 --- /dev/null +++ b/httpclient/ratelimit.go @@ -0,0 +1,231 @@ +package httpclient + +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/httpclient/ratelimit_test.go b/httpclient/ratelimit_test.go new file mode 100644 index 0000000..b70e493 --- /dev/null +++ b/httpclient/ratelimit_test.go @@ -0,0 +1,281 @@ +package httpclient_test + +import ( + "context" + "errors" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/oswaldom-code/go-httpclient/httpclient" + "github.com/oswaldom-code/go-httpclient/httpclient/internal" +) + +func TestTokenBucket_Basic(t *testing.T) { + tb := httpclient.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 := httpclient.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 := httpclient.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 := httpclient.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 := httpclient.NewTokenBucket(1000, 10) + c := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.RateLimit(httpclient.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 := httpclient.NewTokenBucket(1, 1) // 1 req/s, burst of 1 + c := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.RateLimit(httpclient.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, httpclient.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 := httpclient.NewTokenBucket(1000, 100) + c := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.RateLimit(httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.RateLimit(httpclient.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 := httpclient.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 := httpclient.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 := httpclient.NewTokenBucket(1000000, 1000000) + + b.ResetTimer() + b.ReportAllocs() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + tb.TryAcquire() + } + }) +} diff --git a/httpclient/request.go b/httpclient/request.go new file mode 100644 index 0000000..45090a4 --- /dev/null +++ b/httpclient/request.go @@ -0,0 +1,319 @@ +package httpclient + +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/httpclient/request_test.go b/httpclient/request_test.go new file mode 100644 index 0000000..eda62bd --- /dev/null +++ b/httpclient/request_test.go @@ -0,0 +1,381 @@ +package httpclient_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/oswaldom-code/go-httpclient/httpclient" + "github.com/oswaldom-code/go-httpclient/httpclient/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 := httpclient.New(httpclient.WithTransport(rt)) + + resp, err := httpclient.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 := httpclient.New(httpclient.WithTransport(rt)) + + resp, err := httpclient.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 := httpclient.New(httpclient.WithTransport(rt)) + + _, _ = httpclient.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 := httpclient.New(httpclient.WithTransport(rt)) + + _, _ = httpclient.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 := httpclient.New(httpclient.WithTransport(rt)) + + _, _ = httpclient.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 := httpclient.New(httpclient.WithTransport(rt)) + + payload := map[string]string{"name": "test", "value": "123"} + _, _ = httpclient.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 := httpclient.New(httpclient.WithTransport(rt)) + + _, _ = httpclient.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 := httpclient.New(httpclient.WithTransport(rt)) + + _, _ = httpclient.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 := httpclient.New(httpclient.WithTransport(rt)) + + _, _ = httpclient.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 := httpclient.New(httpclient.WithTransport(rt)) + + _, err := httpclient.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 := httpclient.New(httpclient.WithTransport(rt)) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _, err := httpclient.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(*httpclient.RequestBuilder, string) (*http.Response, error) + expect string + }{ + {"Get", func(rb *httpclient.RequestBuilder, url string) (*http.Response, error) { return rb.Get(url) }, "GET"}, + {"Post", func(rb *httpclient.RequestBuilder, url string) (*http.Response, error) { return rb.Post(url) }, "POST"}, + {"Put", func(rb *httpclient.RequestBuilder, url string) (*http.Response, error) { return rb.Put(url) }, "PUT"}, + {"Patch", func(rb *httpclient.RequestBuilder, url string) (*http.Response, error) { return rb.Patch(url) }, "PATCH"}, + {"Delete", func(rb *httpclient.RequestBuilder, url string) (*http.Response, error) { return rb.Delete(url) }, "DELETE"}, + {"Head", func(rb *httpclient.RequestBuilder, url string) (*http.Response, error) { return rb.Head(url) }, "HEAD"}, + {"Options", func(rb *httpclient.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 := httpclient.New(httpclient.WithTransport(rt)) + _, _ = m.fn(httpclient.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 := httpclient.New(httpclient.WithTransport(rt)) + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _, _ = httpclient.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 := httpclient.New(httpclient.WithTransport(rt)) + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _, _ = httpclient.R(c). + SetHeader("Authorization", "Bearer token"). + SetQueryParam("page", "1"). + SetPathParam("id", "123"). + Get("http://example.com/users/{id}") + } +} diff --git a/httpclient/retry.go b/httpclient/retry.go new file mode 100644 index 0000000..bb95d76 --- /dev/null +++ b/httpclient/retry.go @@ -0,0 +1,128 @@ +package httpclient + +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 +} + +//nolint:gocognit // retry logic has inherent complexity +func (r retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if !r.cfg.RetryAllMethods && !isIdempotent(req.Method) { + return r.next.RoundTrip(req) + } + + // Cannot retry if body is not replayable (http.NoBody is safe to retry) + if req.Body != nil && req.Body != http.NoBody && req.GetBody == nil { + return r.next.RoundTrip(req) + } + + var resp *http.Response + var err error + + for attempt := 0; attempt < r.cfg.MaxAttempts; attempt++ { + if attempt > 0 { + // Reset body for retry + if req.GetBody != nil { + req.Body, err = req.GetBody() + if err != nil { + return nil, err + } + } + + // Wait before retry + backoff := r.cfg.Backoff(attempt - 1) + select { + case <-req.Context().Done(): + return nil, req.Context().Err() + case <-time.After(backoff): + } + } + + resp, err = r.next.RoundTrip(req) + + if !r.cfg.IsRetryable(resp, err) { + return resp, err + } + + // Close body before retry to release connection + if resp != nil && resp.Body != nil { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + } + } + + return resp, err +} + +// 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/httpclient/retry_test.go b/httpclient/retry_test.go new file mode 100644 index 0000000..ab04527 --- /dev/null +++ b/httpclient/retry_test.go @@ -0,0 +1,321 @@ +package httpclient_test + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/oswaldom-code/go-httpclient/httpclient" + "github.com/oswaldom-code/go-httpclient/httpclient/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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Retry(httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Retry(httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Retry(httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Retry(httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Retry(httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Retry(httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Retry(httpclient.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_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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Retry(httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Retry(httpclient.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 := httpclient.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/httpclient/timeout.go b/httpclient/timeout.go new file mode 100644 index 0000000..a14eeac --- /dev/null +++ b/httpclient/timeout.go @@ -0,0 +1,37 @@ +package httpclient + +import ( + "context" + "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) + defer cancel() + + req = req.Clone(ctx) + return t.next.RoundTrip(req) +} diff --git a/httpclient/timeout_test.go b/httpclient/timeout_test.go new file mode 100644 index 0000000..fed7ab8 --- /dev/null +++ b/httpclient/timeout_test.go @@ -0,0 +1,116 @@ +package httpclient_test + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/oswaldom-code/go-httpclient/httpclient" + "github.com/oswaldom-code/go-httpclient/httpclient/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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.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_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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.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/httpclient/transport.go b/httpclient/transport.go new file mode 100644 index 0000000..f467c5e --- /dev/null +++ b/httpclient/transport.go @@ -0,0 +1,19 @@ +package httpclient + +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, + } +} From d6c46a5c8d8f0f830fc8601e5398818fee185653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Wed, 1 Jul 2026 22:27:58 +0200 Subject: [PATCH 07/13] chore: Downgrade Go version to 1.21 and update roadmap in README --- README.md | 19 +++++++++++++++++++ go.mod | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cb8e926..5fb3526 100644 --- a/README.md +++ b/README.md @@ -397,6 +397,25 @@ 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 diff --git a/go.mod b/go.mod index 542ad99..0ae54c0 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/oswaldom-code/go-httpclient -go 1.24.0 +go 1.21 From 11d7daefe8a6fa7720a68a9b44123e9c670fe0de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Tue, 21 Jul 2026 19:14:43 +0200 Subject: [PATCH 08/13] fix: keep response body readable in timeout and retry middleware - timeout: cancel the timeout context on Body.Close, not on RoundTrip return, so streaming/chunked bodies aren't aborted mid-read - retry: skip draining/closing the body on the final attempt, since that response is returned to the caller Adds httptest.Server regression tests that read the body after RoundTrip returns. --- httpclient/retry.go | 6 ++++-- httpclient/retry_test.go | 39 ++++++++++++++++++++++++++++++++++++++ httpclient/timeout.go | 26 +++++++++++++++++++++++-- httpclient/timeout_test.go | 37 ++++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 4 deletions(-) diff --git a/httpclient/retry.go b/httpclient/retry.go index bb95d76..0f55377 100644 --- a/httpclient/retry.go +++ b/httpclient/retry.go @@ -88,8 +88,10 @@ func (r retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) return resp, err } - // Close body before retry to release connection - if resp != nil && resp.Body != nil { + // Close body before retrying to release the connection. Skip on the + // final attempt: that response is returned to the caller, who must be + // able to read its body. + if attempt < r.cfg.MaxAttempts-1 && resp != nil && resp.Body != nil { _, _ = io.Copy(io.Discard, resp.Body) _ = resp.Body.Close() } diff --git a/httpclient/retry_test.go b/httpclient/retry_test.go index ab04527..9371f66 100644 --- a/httpclient/retry_test.go +++ b/httpclient/retry_test.go @@ -236,6 +236,45 @@ func TestRetry_RetryableStatusCodes(t *testing.T) { } } +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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.Retry(httpclient.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) { diff --git a/httpclient/timeout.go b/httpclient/timeout.go index a14eeac..28f1114 100644 --- a/httpclient/timeout.go +++ b/httpclient/timeout.go @@ -2,6 +2,7 @@ package httpclient import ( "context" + "io" "net/http" "time" ) @@ -30,8 +31,29 @@ func (t timeoutRoundTripper) RoundTrip(req *http.Request) (*http.Response, error } ctx, cancel := context.WithTimeout(ctx, t.timeout) - defer cancel() req = req.Clone(ctx) - return t.next.RoundTrip(req) + 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/httpclient/timeout_test.go b/httpclient/timeout_test.go index fed7ab8..20d243f 100644 --- a/httpclient/timeout_test.go +++ b/httpclient/timeout_test.go @@ -3,7 +3,9 @@ package httpclient_test import ( "context" "errors" + "io" "net/http" + "net/http/httptest" "testing" "time" @@ -82,6 +84,41 @@ func TestTimeout_RespectsExistingShorterDeadline(t *testing.T) { } } +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 := httpclient.New( + httpclient.WithMiddleware(httpclient.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) { From 078c2f1f8caa4ea33c3fb07c0c2e91e9fdc88a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Tue, 21 Jul 2026 19:15:01 +0200 Subject: [PATCH 09/13] refactor: extract retry helpers to drop gocognit nolint Split retryRoundTripper.RoundTrip into canRetry, prepareRetry and drainAndClose, lowering cognitive complexity below the linter threshold and removing the //nolint:gocognit directive. --- httpclient/retry.go | 65 ++++++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/httpclient/retry.go b/httpclient/retry.go index 0f55377..cb7d8ab 100644 --- a/httpclient/retry.go +++ b/httpclient/retry.go @@ -49,14 +49,8 @@ type retryRoundTripper struct { cfg RetryConfig } -//nolint:gocognit // retry logic has inherent complexity func (r retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - if !r.cfg.RetryAllMethods && !isIdempotent(req.Method) { - return r.next.RoundTrip(req) - } - - // Cannot retry if body is not replayable (http.NoBody is safe to retry) - if req.Body != nil && req.Body != http.NoBody && req.GetBody == nil { + if !r.canRetry(req) { return r.next.RoundTrip(req) } @@ -65,20 +59,8 @@ func (r retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) for attempt := 0; attempt < r.cfg.MaxAttempts; attempt++ { if attempt > 0 { - // Reset body for retry - if req.GetBody != nil { - req.Body, err = req.GetBody() - if err != nil { - return nil, err - } - } - - // Wait before retry - backoff := r.cfg.Backoff(attempt - 1) - select { - case <-req.Context().Done(): - return nil, req.Context().Err() - case <-time.After(backoff): + if err := r.prepareRetry(req, attempt); err != nil { + return nil, err } } @@ -88,18 +70,47 @@ func (r retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) return resp, err } - // Close body before retrying to release the connection. Skip on the - // final attempt: that response is returned to the caller, who must be - // able to read its body. - if attempt < r.cfg.MaxAttempts-1 && resp != nil && resp.Body != nil { - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() + 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 { From 292cdb2171c769214ab4ec912ff55a7c16e1a281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Tue, 21 Jul 2026 19:52:38 +0200 Subject: [PATCH 10/13] fix: enforce single-probe half-open in circuit breaker Add MaxHalfOpenRequests (default 1) and SuccessThreshold (default 1) so Half-Open admits a bounded number of concurrent probes and closes only after enough consecutive successes. Previously the mutex was released between allowRequest and recordResult, letting every concurrent request through in Half-Open. Also split recordResult into recordClosedResult/recordHalfOpenResult, order callees before callers, and add concurrency regression tests. --- httpclient/circuitbreaker.go | 137 +++++++++++++------ httpclient/circuitbreaker_test.go | 218 ++++++++++++++++++++++++++++++ 2 files changed, 310 insertions(+), 45 deletions(-) diff --git a/httpclient/circuitbreaker.go b/httpclient/circuitbreaker.go index c3426f3..a2d4aff 100644 --- a/httpclient/circuitbreaker.go +++ b/httpclient/circuitbreaker.go @@ -26,6 +26,24 @@ type CircuitBreakerConfig struct { // 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. @@ -39,6 +57,12 @@ func CircuitBreaker(cfg CircuitBreakerConfig) Middleware { 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, @@ -55,22 +79,12 @@ type circuitBreaker struct { next http.RoundTripper cfg CircuitBreakerConfig - mu sync.Mutex - state CircuitState - failures int - lastFailureTime time.Time -} - -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 + mu sync.Mutex + state CircuitState + failures int + lastFailureTime time.Time + halfOpenInFlight int + halfOpenSuccess int } func (cb *circuitBreaker) allowRequest() bool { @@ -84,19 +98,61 @@ func (cb *circuitBreaker) allowRequest() bool { 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: - // In half-open state, allow the request (only one at a time due to mutex) - return true + 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() @@ -105,26 +161,27 @@ func (cb *circuitBreaker) recordResult(resp *http.Response, err error) { switch cb.state { case CircuitClosed: - if isFailure { - cb.failures++ - cb.lastFailureTime = time.Now() - if cb.failures >= cb.cfg.FailureThreshold { - cb.state = CircuitOpen - } - } else { - cb.failures = 0 - } + cb.recordClosedResult(isFailure) case CircuitHalfOpen: - if isFailure { - cb.state = CircuitOpen - cb.lastFailureTime = time.Now() - cb.failures = cb.cfg.FailureThreshold - } else { - cb.state = CircuitClosed - cb.failures = 0 - } + 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. @@ -134,13 +191,3 @@ func (cb *circuitBreaker) State() CircuitState { defer cb.mu.Unlock() return cb.state } - -func DefaultIsFailure(resp *http.Response, err error) bool { - if err != nil { - return true - } - if resp != nil && resp.StatusCode >= 500 { - return true - } - return false -} diff --git a/httpclient/circuitbreaker_test.go b/httpclient/circuitbreaker_test.go index 2b3623b..c1fffd7 100644 --- a/httpclient/circuitbreaker_test.go +++ b/httpclient/circuitbreaker_test.go @@ -319,6 +319,224 @@ func TestCircuitBreaker_ThreadSafety(t *testing.T) { } } +// 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 httpclient.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 := httpclient.New( + httpclient.WithTransport(bp.rt()), + httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.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, httpclient.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 := httpclient.New( + httpclient.WithTransport(bp.rt()), + httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.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, httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.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, httpclient.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 := httpclient.New( + httpclient.WithTransport(rt), + httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.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) { From ae44536b6a53dcf3572e48b791d151fb25c43838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Tue, 21 Jul 2026 20:00:07 +0200 Subject: [PATCH 11/13] chore: use math/rand v1 to keep the go 1.21 floor Switch backoff jitter from math/rand/v2 (Go 1.22+) to math/rand so the stdversion warnings go away while go.mod stays at 1.21. Also adopt the min builtin in place of manual max-capping. --- httpclient/backoff.go | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/httpclient/backoff.go b/httpclient/backoff.go index 414e4a3..568605b 100644 --- a/httpclient/backoff.go +++ b/httpclient/backoff.go @@ -1,7 +1,7 @@ package httpclient import ( - "math/rand/v2" + "math/rand" "sync" "time" ) @@ -34,9 +34,7 @@ func LinearBackoff(base, maxDuration time.Duration) BackoffFunc { func ExponentialBackoff(base, maxDuration time.Duration) BackoffFunc { return func(attempt int) time.Duration { backoff := base * (1 << attempt) - if backoff > maxDuration { - backoff = maxDuration - } + 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) @@ -92,9 +90,7 @@ func DecorrelatedJitterBackoff(base, maxDuration time.Duration) BackoffFunc { maxVal := float64(lastBackoff) * 3 backoff := time.Duration(minVal + rand.Float64()*(maxVal-minVal)) //nolint:gosec - if backoff > maxDuration { - backoff = maxDuration - } + backoff = min(backoff, maxDuration) lastBackoff = backoff return backoff } @@ -106,9 +102,7 @@ func DecorrelatedJitterBackoff(base, maxDuration time.Duration) BackoffFunc { func ExponentialBackoffFullJitter(base, maxDuration time.Duration) BackoffFunc { return func(attempt int) time.Duration { ceiling := base * (1 << attempt) - if ceiling > maxDuration { - ceiling = maxDuration - } + ceiling = min(ceiling, maxDuration) return time.Duration(rand.Float64() * float64(ceiling)) //nolint:gosec } } @@ -118,9 +112,7 @@ func ExponentialBackoffFullJitter(base, maxDuration time.Duration) BackoffFunc { func ExponentialBackoffEqualJitter(base, maxDuration time.Duration) BackoffFunc { return func(attempt int) time.Duration { ceiling := base * (1 << attempt) - if ceiling > maxDuration { - ceiling = maxDuration - } + ceiling = min(ceiling, maxDuration) half := ceiling / 2 return half + time.Duration(rand.Float64()*float64(half)) //nolint:gosec } From 34930ae10ab0ecf7c9b4e956ff262c76bd8b76b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Tue, 21 Jul 2026 20:37:06 +0200 Subject: [PATCH 12/13] refactor!: rename module to github.com/oswaldom-code/rhttp Move the package from the httpclient/ subdirectory to the module root and rename it from httpclient to rhttp, dropping the go-httpclient/httpclient import stutter. Updates go.mod, every import and package clause, the sentinel error prefixes (rhttp:), README, Makefile, .golangci.yml, CLAUDE.md and the example. No behavior change: build, race tests and lint pass under the new path. BREAKING CHANGE: import path is now github.com/oswaldom-code/rhttp and the package identifier is rhttp (was .../go-httpclient/httpclient, httpclient). --- .golangci.yml | 2 +- CLAUDE.md | 117 +++++++++++++ Makefile | 4 +- README.md | 130 +++++++-------- httpclient/backoff.go => backoff.go | 2 +- httpclient/backoff_test.go => backoff_test.go | 46 ++--- .../benchmark_test.go => benchmark_test.go | 72 ++++---- .../circuitbreaker.go => circuitbreaker.go | 2 +- ...tbreaker_test.go => circuitbreaker_test.go | 104 ++++++------ httpclient/client.go => client.go | 2 +- httpclient/client_test.go => client_test.go | 18 +- httpclient/doc.go => doc.go | 22 +-- httpclient/errorclass.go => errorclass.go | 2 +- .../errorclass_test.go => errorclass_test.go | 118 ++++++------- httpclient/errors.go => errors.go | 8 +- httpclient/example_test.go => example_test.go | 66 ++++---- examples/basic/main.go | 157 ++++++++++++++++++ go.mod | 2 +- .../internal => internal}/roundtripper.go | 2 +- httpclient/logging.go => logging.go | 2 +- httpclient/logging_test.go => logging_test.go | 60 +++---- httpclient/metrics.go => metrics.go | 2 +- httpclient/metrics_test.go => metrics_test.go | 82 ++++----- httpclient/middleware.go => middleware.go | 2 +- httpclient/options.go => options.go | 2 +- httpclient/pool.go => pool.go | 2 +- httpclient/pool_test.go => pool_test.go | 30 ++-- httpclient/ratelimit.go => ratelimit.go | 2 +- .../ratelimit_test.go => ratelimit_test.go | 52 +++--- httpclient/request.go => request.go | 2 +- httpclient/request_test.go => request_test.go | 78 ++++----- httpclient/retry.go => retry.go | 2 +- httpclient/retry_test.go => retry_test.go | 68 ++++---- httpclient/timeout.go => timeout.go | 2 +- httpclient/timeout_test.go => timeout_test.go | 34 ++-- httpclient/transport.go => transport.go | 2 +- 36 files changed, 787 insertions(+), 513 deletions(-) create mode 100644 CLAUDE.md rename httpclient/backoff.go => backoff.go (99%) rename httpclient/backoff_test.go => backoff_test.go (74%) rename httpclient/benchmark_test.go => benchmark_test.go (65%) rename httpclient/circuitbreaker.go => circuitbreaker.go (99%) rename httpclient/circuitbreaker_test.go => circuitbreaker_test.go (85%) rename httpclient/client.go => client.go (97%) rename httpclient/client_test.go => client_test.go (82%) rename httpclient/doc.go => doc.go (83%) rename httpclient/errorclass.go => errorclass.go (99%) rename httpclient/errorclass_test.go => errorclass_test.go (64%) rename httpclient/errors.go => errors.go (54%) rename httpclient/example_test.go => example_test.go (71%) create mode 100644 examples/basic/main.go rename {httpclient/internal => internal}/roundtripper.go (84%) rename httpclient/logging.go => logging.go (99%) rename httpclient/logging_test.go => logging_test.go (76%) rename httpclient/metrics.go => metrics.go (99%) rename httpclient/metrics_test.go => metrics_test.go (73%) rename httpclient/middleware.go => middleware.go (95%) rename httpclient/options.go => options.go (96%) rename httpclient/pool.go => pool.go (99%) rename httpclient/pool_test.go => pool_test.go (81%) rename httpclient/ratelimit.go => ratelimit.go (99%) rename httpclient/ratelimit_test.go => ratelimit_test.go (81%) rename httpclient/request.go => request.go (99%) rename httpclient/request_test.go => request_test.go (81%) rename httpclient/retry.go => retry.go (99%) rename httpclient/retry_test.go => retry_test.go (85%) rename httpclient/timeout.go => timeout.go (98%) rename httpclient/timeout_test.go => timeout_test.go (84%) rename httpclient/transport.go => transport.go (95%) diff --git a/.golangci.yml b/.golangci.yml index bace925..d09043a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -60,7 +60,7 @@ linters-settings: simplify: true goimports: - local-prefixes: github.com/oswaldom-code/go-httpclient + local-prefixes: github.com/oswaldom-code/rhttp gosec: excludes: 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 index b22c29b..75881fc 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ RED=\033[0;31m NC=\033[0m # No Color help: - @echo "go-httpclient - Production-grade HTTP client for Go" + @echo "rhttp - Production-grade HTTP client for Go" @echo "" @echo "Usage: make [target]" @echo "" @@ -91,7 +91,7 @@ vet: docs: @echo "$(GREEN)Starting documentation server...$(NC)" - @echo "Open http://localhost:8080/github.com/oswaldom-code/go-httpclient/httpclient" + @echo "Open http://localhost:8080/github.com/oswaldom-code/rhttp" @if command -v pkgsite >/dev/null 2>&1; then \ pkgsite -http=:8080; \ else \ diff --git a/README.md b/README.md index 5fb3526..d76d006 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# go-httpclient +# rhttp Production-grade HTTP client for Go with built-in resiliency patterns. -[![CI](https://github.com/oswaldom-code/go-httpclient/actions/workflows/ci.yml/badge.svg)](https://github.com/oswaldom-code/go-httpclient/actions/workflows/ci.yml) -[![codecov](https://codecov.io/gh/oswaldom-code/go-httpclient/branch/main/graph/badge.svg)](https://codecov.io/gh/oswaldom-code/go-httpclient) -[![Go Report Card](https://goreportcard.com/badge/github.com/oswaldom-code/go-httpclient)](https://goreportcard.com/report/github.com/oswaldom-code/go-httpclient) -[![Go Reference](https://pkg.go.dev/badge/github.com/oswaldom-code/go-httpclient.svg)](https://pkg.go.dev/github.com/oswaldom-code/go-httpclient) +[![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/) @@ -28,7 +28,7 @@ Esta librería resuelve ese problema: **resiliencia production-ready con cero de | Modo | Cuándo usarlo | |------|---------------| | `go get` | Proyectos que aceptan dependencias externas | -| Copiar a `pkg/httpclient` | Políticas estrictas de zero-deps, vendor everything | +| 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. @@ -46,7 +46,7 @@ El código está diseñado para funcionar en ambos escenarios sin modificaciones ## Installation ```bash -go get github.com/oswaldom-code/go-httpclient +go get github.com/oswaldom-code/rhttp ``` Requires Go 1.21+ @@ -64,16 +64,16 @@ import ( "net/http" "time" - "github.com/oswaldom-code/go-httpclient/httpclient" + "github.com/oswaldom-code/rhttp" ) func main() { // Create client with middleware - client := httpclient.New( - httpclient.WithMiddleware( - httpclient.Timeout(5*time.Second), - httpclient.Retry(httpclient.RetryConfig{MaxAttempts: 3}), - httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + 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, }), @@ -95,17 +95,17 @@ func main() { ### Fluent API ```go -client := httpclient.New() +client := rhttp.New() // GET request with query params -resp, err := httpclient.R(client). +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 := httpclient.R(client). +resp, err := rhttp.R(client). SetAuthToken("my-token"). SetBodyJSON(map[string]string{ "name": "John", @@ -114,7 +114,7 @@ resp, err := httpclient.R(client). Post("https://api.example.com/users") // Path parameters -resp, err := httpclient.R(client). +resp, err := rhttp.R(client). SetPathParam("org", "acme"). SetPathParam("repo", "api"). Get("https://api.github.com/repos/{org}/{repo}") @@ -125,9 +125,9 @@ resp, err := httpclient.R(client). ### Timeout ```go -client := httpclient.New( - httpclient.WithMiddleware( - httpclient.Timeout(5*time.Second), +client := rhttp.New( + rhttp.WithMiddleware( + rhttp.Timeout(5*time.Second), ), ) ``` @@ -137,12 +137,12 @@ Respects existing context deadlines - uses the shorter of the two. ### Retry ```go -client := httpclient.New( - httpclient.WithMiddleware( - httpclient.Retry(httpclient.RetryConfig{ +client := rhttp.New( + rhttp.WithMiddleware( + rhttp.Retry(rhttp.RetryConfig{ MaxAttempts: 3, - Backoff: httpclient.ExponentialBackoff(100*time.Millisecond, 10*time.Second), - IsRetryable: httpclient.DefaultIsRetryable, // 429, 502, 503, 504 + Backoff: rhttp.ExponentialBackoff(100*time.Millisecond, 10*time.Second), + IsRetryable: rhttp.DefaultIsRetryable, // 429, 502, 503, 504 RetryAllMethods: false, // Only retry idempotent methods by default }), ), @@ -166,12 +166,12 @@ Composable with `WithJitter()`, `WithMin()`, `WithMax()`. ### Circuit Breaker ```go -client := httpclient.New( - httpclient.WithMiddleware( - httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ +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: httpclient.DefaultIsFailure, // Errors + 5xx + IsFailure: rhttp.DefaultIsFailure, // Errors + 5xx }), ), ) @@ -179,17 +179,17 @@ client := httpclient.New( State machine: `Closed → Open → Half-Open → Closed/Open` -Returns `httpclient.ErrCircuitOpen` when circuit is open. +Returns `rhttp.ErrCircuitOpen` when circuit is open. ### Rate Limiting ```go // Token bucket: 100 requests/second, burst of 10 -limiter := httpclient.NewTokenBucket(100, 10) +limiter := rhttp.NewTokenBucket(100, 10) -client := httpclient.New( - httpclient.WithMiddleware( - httpclient.RateLimit(httpclient.RateLimitConfig{ +client := rhttp.New( + rhttp.WithMiddleware( + rhttp.RateLimit(rhttp.RateLimitConfig{ Limiter: limiter, WaitOnLimit: true, // Block until token available RespectRetryAfter: true, // Honor Retry-After header @@ -198,16 +198,16 @@ client := httpclient.New( ) // Per-host rate limiting -perHostLimiter := httpclient.NewPerHostRateLimiter(50, 5) // 50 req/s per host +perHostLimiter := rhttp.NewPerHostRateLimiter(50, 5) // 50 req/s per host ``` ### Logging ```go -client := httpclient.New( - httpclient.WithMiddleware( - httpclient.Logging(httpclient.LoggingConfig{ - Logger: httpclient.LoggerFunc(func(e httpclient.LogEntry) { +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 { @@ -221,10 +221,10 @@ client := httpclient.New( ### Metrics ```go -client := httpclient.New( - httpclient.WithMiddleware( - httpclient.Metrics(httpclient.MetricsConfig{ - Recorder: httpclient.MetricsRecorderFunc(func(e httpclient.MetricEvent) { +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()) @@ -241,25 +241,25 @@ client := httpclient.New( ```go resp, err := client.Do(ctx, req) if err != nil { - classified := httpclient.Classify(err) + classified := rhttp.Classify(err) switch classified.Kind { - case httpclient.ErrKindTimeout: + case rhttp.ErrKindTimeout: // Request timed out - case httpclient.ErrKindCancelled: + case rhttp.ErrKindCancelled: // Context was cancelled - case httpclient.ErrKindConnection: + case rhttp.ErrKindConnection: // Connection refused, reset, etc. - case httpclient.ErrKindDNS: + case rhttp.ErrKindDNS: // DNS resolution failed - case httpclient.ErrKindTLS: + case rhttp.ErrKindTLS: // Certificate error - case httpclient.ErrKindTemporary: + case rhttp.ErrKindTemporary: // Temporary error, may resolve on retry } // Or use helpers - if httpclient.IsRetryable(err) { + if rhttp.IsRetryable(err) { // Safe to retry (timeout, connection, DNS, temporary) } } @@ -270,14 +270,14 @@ if err != nil { Middleware executes in the order specified: ```go -client := httpclient.New( - httpclient.WithMiddleware( - httpclient.Logging(...), // 1. Log request start - httpclient.Metrics(...), // 2. Start timing - httpclient.Timeout(...), // 3. Apply timeout - httpclient.RateLimit(...), // 4. Check rate limit - httpclient.CircuitBreaker(...), // 5. Check circuit - httpclient.Retry(...), // 6. Retry on failure +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 ), ) ``` @@ -288,8 +288,8 @@ Recommended order: `Logging → Metrics → Timeout → RateLimit → CircuitBre ```go // Use custom transport -client := httpclient.New( - httpclient.WithTransport(&http.Transport{ +client := rhttp.New( + rhttp.WithTransport(&http.Transport{ MaxIdleConns: 200, MaxIdleConnsPerHost: 20, IdleConnTimeout: 90*time.Second, @@ -297,7 +297,7 @@ client := httpclient.New( ) // Or use optimized default -transport := httpclient.DefaultTransport() // HTTP/2 enabled, optimized pool +transport := rhttp.DefaultTransport() // HTTP/2 enabled, optimized pool ``` ## Object Pooling @@ -306,8 +306,8 @@ Reduce allocations with buffer pooling: ```go // Get a buffer from the pool -buf := httpclient.GetBuffer() -defer httpclient.PutBuffer(buf) +buf := rhttp.GetBuffer() +defer rhttp.PutBuffer(buf) buf.WriteString("request body") ``` @@ -345,7 +345,7 @@ BenchmarkBackoff_Exponential-12 7 ns/op 0 B/op 0 allocs/op ## API Reference -See [pkg.go.dev](https://pkg.go.dev/github.com/oswaldom-code/go-httpclient/httpclient) for full API documentation. +See [pkg.go.dev](https://pkg.go.dev/github.com/oswaldom-code/rhttp) for full API documentation. ## Development diff --git a/httpclient/backoff.go b/backoff.go similarity index 99% rename from httpclient/backoff.go rename to backoff.go index 568605b..3e656bc 100644 --- a/httpclient/backoff.go +++ b/backoff.go @@ -1,4 +1,4 @@ -package httpclient +package rhttp import ( "math/rand" diff --git a/httpclient/backoff_test.go b/backoff_test.go similarity index 74% rename from httpclient/backoff_test.go rename to backoff_test.go index b16479c..8f65b30 100644 --- a/httpclient/backoff_test.go +++ b/backoff_test.go @@ -1,14 +1,14 @@ -package httpclient_test +package rhttp_test import ( "testing" "time" - "github.com/oswaldom-code/go-httpclient/httpclient" + "github.com/oswaldom-code/rhttp" ) func TestConstantBackoff(t *testing.T) { - backoff := httpclient.ConstantBackoff(100 * time.Millisecond) + backoff := rhttp.ConstantBackoff(100 * time.Millisecond) for attempt := 0; attempt < 10; attempt++ { d := backoff(attempt) @@ -19,7 +19,7 @@ func TestConstantBackoff(t *testing.T) { } func TestLinearBackoff(t *testing.T) { - backoff := httpclient.LinearBackoff(100*time.Millisecond, 500*time.Millisecond) + backoff := rhttp.LinearBackoff(100*time.Millisecond, 500*time.Millisecond) expected := []time.Duration{ 100 * time.Millisecond, // attempt 0: 100 * 1 @@ -39,7 +39,7 @@ func TestLinearBackoff(t *testing.T) { } func TestExponentialBackoff_Growth(t *testing.T) { - backoff := httpclient.ExponentialBackoff(100*time.Millisecond, 10*time.Second) + backoff := rhttp.ExponentialBackoff(100*time.Millisecond, 10*time.Second) // Test exponential growth (with tolerance for jitter) expectedBase := []time.Duration{ @@ -61,7 +61,7 @@ func TestExponentialBackoff_Growth(t *testing.T) { } func TestExponentialBackoff_Max(t *testing.T) { - backoff := httpclient.ExponentialBackoff(100*time.Millisecond, 500*time.Millisecond) + backoff := rhttp.ExponentialBackoff(100*time.Millisecond, 500*time.Millisecond) // After a few attempts, should be capped at max d := backoff(10) @@ -72,7 +72,7 @@ func TestExponentialBackoff_Max(t *testing.T) { } func TestFibonacciBackoff(t *testing.T) { - backoff := httpclient.FibonacciBackoff(100*time.Millisecond, 10*time.Second) + backoff := rhttp.FibonacciBackoff(100*time.Millisecond, 10*time.Second) // Fibonacci: 1, 1, 2, 3, 5, 8, 13... expected := []time.Duration{ @@ -93,7 +93,7 @@ func TestFibonacciBackoff(t *testing.T) { } func TestFibonacciBackoff_Max(t *testing.T) { - backoff := httpclient.FibonacciBackoff(100*time.Millisecond, 500*time.Millisecond) + backoff := rhttp.FibonacciBackoff(100*time.Millisecond, 500*time.Millisecond) // Should cap at 500ms d := backoff(10) @@ -103,7 +103,7 @@ func TestFibonacciBackoff_Max(t *testing.T) { } func TestDecorrelatedJitterBackoff(t *testing.T) { - backoff := httpclient.DecorrelatedJitterBackoff(100*time.Millisecond, 10*time.Second) + backoff := rhttp.DecorrelatedJitterBackoff(100*time.Millisecond, 10*time.Second) // First attempt should be base d0 := backoff(0) @@ -122,7 +122,7 @@ func TestDecorrelatedJitterBackoff(t *testing.T) { } func TestExponentialBackoffFullJitter(t *testing.T) { - backoff := httpclient.ExponentialBackoffFullJitter(100*time.Millisecond, 10*time.Second) + backoff := rhttp.ExponentialBackoffFullJitter(100*time.Millisecond, 10*time.Second) for attempt := 0; attempt < 5; attempt++ { d := backoff(attempt) @@ -139,7 +139,7 @@ func TestExponentialBackoffFullJitter(t *testing.T) { } func TestExponentialBackoffEqualJitter(t *testing.T) { - backoff := httpclient.ExponentialBackoffEqualJitter(100*time.Millisecond, 10*time.Second) + backoff := rhttp.ExponentialBackoffEqualJitter(100*time.Millisecond, 10*time.Second) for attempt := 0; attempt < 5; attempt++ { d := backoff(attempt) @@ -157,8 +157,8 @@ func TestExponentialBackoffEqualJitter(t *testing.T) { } func TestWithJitter(t *testing.T) { - constant := httpclient.ConstantBackoff(100 * time.Millisecond) - withJitter := httpclient.WithJitter(constant, 0.5) // 50% jitter + 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 @@ -182,8 +182,8 @@ func TestWithJitter(t *testing.T) { } func TestWithMax(t *testing.T) { - linear := httpclient.LinearBackoff(100*time.Millisecond, 10*time.Second) - capped := httpclient.WithMax(linear, 300*time.Millisecond) + 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) @@ -193,8 +193,8 @@ func TestWithMax(t *testing.T) { } func TestWithMin(t *testing.T) { - constant := httpclient.ConstantBackoff(10 * time.Millisecond) - withMin := httpclient.WithMin(constant, 100*time.Millisecond) + constant := rhttp.ConstantBackoff(10 * time.Millisecond) + withMin := rhttp.WithMin(constant, 100*time.Millisecond) d := withMin(0) if d != 100*time.Millisecond { @@ -203,12 +203,12 @@ func TestWithMin(t *testing.T) { } func BenchmarkBackoffStrategies(b *testing.B) { - strategies := map[string]httpclient.BackoffFunc{ - "Constant": httpclient.ConstantBackoff(100 * time.Millisecond), - "Linear": httpclient.LinearBackoff(100*time.Millisecond, 10*time.Second), - "Exponential": httpclient.ExponentialBackoff(100*time.Millisecond, 10*time.Second), - "Fibonacci": httpclient.FibonacciBackoff(100*time.Millisecond, 10*time.Second), - "FullJitter": httpclient.ExponentialBackoffFullJitter(100*time.Millisecond, 10*time.Second), + 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 { diff --git a/httpclient/benchmark_test.go b/benchmark_test.go similarity index 65% rename from httpclient/benchmark_test.go rename to benchmark_test.go index 44c06cf..7dd1e6d 100644 --- a/httpclient/benchmark_test.go +++ b/benchmark_test.go @@ -1,4 +1,4 @@ -package httpclient_test +package rhttp_test import ( "context" @@ -6,8 +6,8 @@ import ( "testing" "time" - "github.com/oswaldom-code/go-httpclient/httpclient" - "github.com/oswaldom-code/go-httpclient/httpclient/internal" + "github.com/oswaldom-code/rhttp" + "github.com/oswaldom-code/rhttp/internal" ) // noopRoundTripper returns immediately with a 200 OK response. @@ -21,13 +21,13 @@ var noopRoundTripper = internal.RoundTripperFunc(func(req *http.Request) (*http. }) // noopLogger discards all log entries -var noopLogger = httpclient.LoggerFunc(func(httpclient.LogEntry) {}) +var noopLogger = rhttp.LoggerFunc(func(rhttp.LogEntry) {}) // noopRecorder discards all metric events -var noopRecorder = httpclient.MetricsRecorderFunc(func(httpclient.MetricEvent) {}) +var noopRecorder = rhttp.MetricsRecorderFunc(func(rhttp.MetricEvent) {}) func BenchmarkClient_Baseline(b *testing.B) { - c := httpclient.New(httpclient.WithTransport(noopRoundTripper)) + c := rhttp.New(rhttp.WithTransport(noopRoundTripper)) req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) ctx := context.Background() @@ -40,9 +40,9 @@ func BenchmarkClient_Baseline(b *testing.B) { } func BenchmarkClient_WithTimeout(b *testing.B) { - c := httpclient.New( - httpclient.WithTransport(noopRoundTripper), - httpclient.WithMiddleware(httpclient.Timeout(5*time.Second)), + 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() @@ -56,9 +56,9 @@ func BenchmarkClient_WithTimeout(b *testing.B) { } func BenchmarkClient_WithRetry(b *testing.B) { - c := httpclient.New( - httpclient.WithTransport(noopRoundTripper), - httpclient.WithMiddleware(httpclient.Retry(httpclient.RetryConfig{ + c := rhttp.New( + rhttp.WithTransport(noopRoundTripper), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ MaxAttempts: 3, })), ) @@ -74,9 +74,9 @@ func BenchmarkClient_WithRetry(b *testing.B) { } func BenchmarkClient_WithCircuitBreaker(b *testing.B) { - c := httpclient.New( - httpclient.WithTransport(noopRoundTripper), - httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + c := rhttp.New( + rhttp.WithTransport(noopRoundTripper), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ FailureThreshold: 5, ResetTimeout: 30 * time.Second, })), @@ -93,9 +93,9 @@ func BenchmarkClient_WithCircuitBreaker(b *testing.B) { } func BenchmarkClient_WithLogging(b *testing.B) { - c := httpclient.New( - httpclient.WithTransport(noopRoundTripper), - httpclient.WithMiddleware(httpclient.Logging(httpclient.LoggingConfig{ + c := rhttp.New( + rhttp.WithTransport(noopRoundTripper), + rhttp.WithMiddleware(rhttp.Logging(rhttp.LoggingConfig{ Logger: noopLogger, })), ) @@ -111,9 +111,9 @@ func BenchmarkClient_WithLogging(b *testing.B) { } func BenchmarkClient_WithMetrics(b *testing.B) { - c := httpclient.New( - httpclient.WithTransport(noopRoundTripper), - httpclient.WithMiddleware(httpclient.Metrics(httpclient.MetricsConfig{ + c := rhttp.New( + rhttp.WithTransport(noopRoundTripper), + rhttp.WithMiddleware(rhttp.Metrics(rhttp.MetricsConfig{ Recorder: noopRecorder, })), ) @@ -129,17 +129,17 @@ func BenchmarkClient_WithMetrics(b *testing.B) { } func BenchmarkClient_AllMiddleware(b *testing.B) { - c := httpclient.New( - httpclient.WithTransport(noopRoundTripper), - httpclient.WithMiddleware( - httpclient.Timeout(5*time.Second), - httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + c := rhttp.New( + rhttp.WithTransport(noopRoundTripper), + rhttp.WithMiddleware( + rhttp.Timeout(5*time.Second), + rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ FailureThreshold: 5, ResetTimeout: 30 * time.Second, }), - httpclient.Retry(httpclient.RetryConfig{MaxAttempts: 3}), - httpclient.Logging(httpclient.LoggingConfig{Logger: noopLogger}), - httpclient.Metrics(httpclient.MetricsConfig{Recorder: noopRecorder}), + 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) @@ -154,15 +154,15 @@ func BenchmarkClient_AllMiddleware(b *testing.B) { } func BenchmarkClient_Parallel(b *testing.B) { - c := httpclient.New( - httpclient.WithTransport(noopRoundTripper), - httpclient.WithMiddleware( - httpclient.Timeout(5*time.Second), - httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + c := rhttp.New( + rhttp.WithTransport(noopRoundTripper), + rhttp.WithMiddleware( + rhttp.Timeout(5*time.Second), + rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ FailureThreshold: 5, ResetTimeout: 30 * time.Second, }), - httpclient.Retry(httpclient.RetryConfig{MaxAttempts: 3}), + rhttp.Retry(rhttp.RetryConfig{MaxAttempts: 3}), ), ) req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) @@ -200,6 +200,6 @@ func BenchmarkClassify_Error(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { - _ = httpclient.Classify(err) + _ = rhttp.Classify(err) } } diff --git a/httpclient/circuitbreaker.go b/circuitbreaker.go similarity index 99% rename from httpclient/circuitbreaker.go rename to circuitbreaker.go index a2d4aff..06ddff5 100644 --- a/httpclient/circuitbreaker.go +++ b/circuitbreaker.go @@ -1,4 +1,4 @@ -package httpclient +package rhttp import ( "net/http" diff --git a/httpclient/circuitbreaker_test.go b/circuitbreaker_test.go similarity index 85% rename from httpclient/circuitbreaker_test.go rename to circuitbreaker_test.go index c1fffd7..576fbd7 100644 --- a/httpclient/circuitbreaker_test.go +++ b/circuitbreaker_test.go @@ -1,4 +1,4 @@ -package httpclient_test +package rhttp_test import ( "context" @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/oswaldom-code/go-httpclient/httpclient" - "github.com/oswaldom-code/go-httpclient/httpclient/internal" + "github.com/oswaldom-code/rhttp" + "github.com/oswaldom-code/rhttp/internal" ) func TestCircuitBreaker_ClosedState_AllowsRequests(t *testing.T) { @@ -20,9 +20,9 @@ func TestCircuitBreaker_ClosedState_AllowsRequests(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ FailureThreshold: 3, })), ) @@ -51,9 +51,9 @@ func TestCircuitBreaker_OpensAfterFailureThreshold(t *testing.T) { return nil, errors.New("connection refused") }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ FailureThreshold: 3, ResetTimeout: 1 * time.Hour, // Long timeout so it stays open })), @@ -73,7 +73,7 @@ func TestCircuitBreaker_OpensAfterFailureThreshold(t *testing.T) { req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) _, err := c.Do(context.Background(), req) - if !errors.Is(err, httpclient.ErrCircuitOpen) { + if !errors.Is(err, rhttp.ErrCircuitOpen) { t.Fatalf("expected ErrCircuitOpen, got %v", err) } @@ -94,9 +94,9 @@ func TestCircuitBreaker_TransitionsToHalfOpenAfterTimeout(t *testing.T) { return nil, errors.New("connection refused") }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ FailureThreshold: 2, ResetTimeout: 50 * time.Millisecond, })), @@ -111,7 +111,7 @@ func TestCircuitBreaker_TransitionsToHalfOpenAfterTimeout(t *testing.T) { // Verify circuit is open req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) _, err := c.Do(context.Background(), req) - if !errors.Is(err, httpclient.ErrCircuitOpen) { + if !errors.Is(err, rhttp.ErrCircuitOpen) { t.Fatalf("expected circuit to be open, got %v", err) } @@ -141,9 +141,9 @@ func TestCircuitBreaker_HalfOpenSuccessCloses(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ FailureThreshold: 2, ResetTimeout: 10 * time.Millisecond, })), @@ -180,9 +180,9 @@ func TestCircuitBreaker_HalfOpenFailureReopens(t *testing.T) { return nil, errors.New("connection refused") }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ FailureThreshold: 2, ResetTimeout: 10 * time.Millisecond, })), @@ -205,7 +205,7 @@ func TestCircuitBreaker_HalfOpenFailureReopens(t *testing.T) { req, _ = http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) _, err := c.Do(context.Background(), req) - if !errors.Is(err, httpclient.ErrCircuitOpen) { + if !errors.Is(err, rhttp.ErrCircuitOpen) { t.Fatalf("expected circuit to reopen after half-open failure, got %v", err) } } @@ -221,9 +221,9 @@ func TestCircuitBreaker_SuccessResetsFailureCount(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ FailureThreshold: 3, ResetTimeout: 1 * time.Hour, })), @@ -250,7 +250,7 @@ func TestCircuitBreaker_SuccessResetsFailureCount(t *testing.T) { _, err := c.Do(context.Background(), req) // Should not be ErrCircuitOpen (might be connection refused or success) - if errors.Is(err, httpclient.ErrCircuitOpen) { + if errors.Is(err, rhttp.ErrCircuitOpen) { t.Fatal("circuit should not be open - success should have reset failure count") } } @@ -262,9 +262,9 @@ func TestCircuitBreaker_5xxStatusCountsAsFailure(t *testing.T) { return &http.Response{StatusCode: http.StatusInternalServerError, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ FailureThreshold: 2, ResetTimeout: 1 * time.Hour, })), @@ -280,7 +280,7 @@ func TestCircuitBreaker_5xxStatusCountsAsFailure(t *testing.T) { req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) _, err := c.Do(context.Background(), req) - if !errors.Is(err, httpclient.ErrCircuitOpen) { + if !errors.Is(err, rhttp.ErrCircuitOpen) { t.Fatalf("expected circuit to open after 5xx responses, got %v", err) } if calls != 2 { @@ -295,9 +295,9 @@ func TestCircuitBreaker_ThreadSafety(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ FailureThreshold: 100, })), ) @@ -349,7 +349,7 @@ func (b *blockingProbe) rt() internal.RoundTripperFunc { } } -func openCircuit(t *testing.T, c httpclient.Client, times int) { +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) @@ -362,9 +362,9 @@ func openCircuit(t *testing.T, c httpclient.Client, times int) { // recordResult, so a naive implementation lets all concurrent requests through. func TestCircuitBreaker_HalfOpenAdmitsSingleProbeByDefault(t *testing.T) { bp := newBlockingProbe() - c := httpclient.New( - httpclient.WithTransport(bp.rt()), - httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + c := rhttp.New( + rhttp.WithTransport(bp.rt()), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ FailureThreshold: 2, ResetTimeout: 10 * time.Millisecond, })), @@ -388,7 +388,7 @@ func TestCircuitBreaker_HalfOpenAdmitsSingleProbeByDefault(t *testing.T) { 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, httpclient.ErrCircuitOpen) { + if !errors.Is(err, rhttp.ErrCircuitOpen) { t.Fatalf("expected ErrCircuitOpen for concurrent probe, got %v", err) } } @@ -404,9 +404,9 @@ func TestCircuitBreaker_HalfOpenAdmitsSingleProbeByDefault(t *testing.T) { func TestCircuitBreaker_HalfOpenRespectsMaxHalfOpenRequests(t *testing.T) { const maxProbes = 3 bp := newBlockingProbe() - c := httpclient.New( - httpclient.WithTransport(bp.rt()), - httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + c := rhttp.New( + rhttp.WithTransport(bp.rt()), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ FailureThreshold: 2, ResetTimeout: 10 * time.Millisecond, MaxHalfOpenRequests: maxProbes, @@ -433,7 +433,7 @@ func TestCircuitBreaker_HalfOpenRespectsMaxHalfOpenRequests(t *testing.T) { // 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, httpclient.ErrCircuitOpen) { + 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) } @@ -454,9 +454,9 @@ func TestCircuitBreaker_OneSuccessDoesNotCloseWithThreshold(t *testing.T) { return nil, errors.New("connection refused") }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ FailureThreshold: 2, ResetTimeout: 10 * time.Millisecond, SuccessThreshold: 2, @@ -480,7 +480,7 @@ func TestCircuitBreaker_OneSuccessDoesNotCloseWithThreshold(t *testing.T) { req, _ = http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) _, err := c.Do(context.Background(), req) - if !errors.Is(err, httpclient.ErrCircuitOpen) { + if !errors.Is(err, rhttp.ErrCircuitOpen) { t.Fatalf("one success must not close the circuit when SuccessThreshold=2, got %v", err) } } @@ -496,9 +496,9 @@ func TestCircuitBreaker_ClosesAfterSuccessThreshold(t *testing.T) { return nil, errors.New("connection refused") }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ FailureThreshold: 2, ResetTimeout: 10 * time.Millisecond, SuccessThreshold: 2, @@ -556,9 +556,9 @@ func TestCircuitBreaker_CustomIsFailure(t *testing.T) { return false } - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ FailureThreshold: 2, ResetTimeout: 1 * time.Hour, IsFailure: customIsFailure, @@ -575,7 +575,7 @@ func TestCircuitBreaker_CustomIsFailure(t *testing.T) { req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) _, err := c.Do(context.Background(), req) - if !errors.Is(err, httpclient.ErrCircuitOpen) { + if !errors.Is(err, rhttp.ErrCircuitOpen) { t.Fatalf("expected circuit to open with custom IsFailure, got %v", err) } } diff --git a/httpclient/client.go b/client.go similarity index 97% rename from httpclient/client.go rename to client.go index 6e2a3f1..19dced6 100644 --- a/httpclient/client.go +++ b/client.go @@ -1,4 +1,4 @@ -package httpclient +package rhttp import ( "context" diff --git a/httpclient/client_test.go b/client_test.go similarity index 82% rename from httpclient/client_test.go rename to client_test.go index a77d4a9..175c157 100644 --- a/httpclient/client_test.go +++ b/client_test.go @@ -1,12 +1,12 @@ -package httpclient_test +package rhttp_test import ( "context" "net/http" "testing" - "github.com/oswaldom-code/go-httpclient/httpclient" - "github.com/oswaldom-code/go-httpclient/httpclient/internal" + "github.com/oswaldom-code/rhttp" + "github.com/oswaldom-code/rhttp/internal" ) func TestClient_Do(t *testing.T) { @@ -17,7 +17,7 @@ func TestClient_Do(t *testing.T) { }, nil }) - c := httpclient.New(httpclient.WithTransport(rt)) + c := rhttp.New(rhttp.WithTransport(rt)) req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) resp, err := c.Do(context.Background(), req) @@ -31,10 +31,10 @@ func TestClient_Do(t *testing.T) { } func TestClient_Do_NilRequest(t *testing.T) { - c := httpclient.New() + c := rhttp.New() _, err := c.Do(context.Background(), nil) - if err != httpclient.ErrInvalidRequest { + if err != rhttp.ErrInvalidRequest { t.Fatalf("expected ErrInvalidRequest, got: %v", err) } } @@ -61,9 +61,9 @@ func TestClient_MiddlewareChain(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(base), - httpclient.WithMiddleware(mw1, mw2), + c := rhttp.New( + rhttp.WithTransport(base), + rhttp.WithMiddleware(mw1, mw2), ) req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) diff --git a/httpclient/doc.go b/doc.go similarity index 83% rename from httpclient/doc.go rename to doc.go index c975bc4..2de37ed 100644 --- a/httpclient/doc.go +++ b/doc.go @@ -1,4 +1,4 @@ -// Package httpclient provides a production-grade HTTP client for Go with built-in +// 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. // @@ -6,16 +6,16 @@ // // Create a client with default settings: // -// client := httpclient.New() +// client := rhttp.New() // resp, err := client.Do(ctx, req) // // Create a client with middleware: // -// client := httpclient.New( -// httpclient.WithMiddleware( -// httpclient.Timeout(5*time.Second), -// httpclient.Retry(httpclient.RetryConfig{MaxAttempts: 3}), -// httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ +// 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, // }), @@ -41,7 +41,7 @@ // // For a more ergonomic API, use the RequestBuilder: // -// resp, err := httpclient.R(client). +// resp, err := rhttp.R(client). // SetHeader("Authorization", "Bearer token"). // SetQueryParam("page", "1"). // SetBodyJSON(payload). @@ -62,8 +62,8 @@ // // Errors are automatically classified using [Classify] to help with retry decisions: // -// classified := httpclient.Classify(err) -// if classified.Kind == httpclient.ErrKindTimeout { +// classified := rhttp.Classify(err) +// if classified.Kind == rhttp.ErrKindTimeout { // // Handle timeout // } // @@ -80,4 +80,4 @@ // // This package has no external dependencies beyond the Go standard library, // making it suitable for projects that require minimal dependency footprint. -package httpclient +package rhttp diff --git a/httpclient/errorclass.go b/errorclass.go similarity index 99% rename from httpclient/errorclass.go rename to errorclass.go index 17aa805..748429b 100644 --- a/httpclient/errorclass.go +++ b/errorclass.go @@ -1,4 +1,4 @@ -package httpclient +package rhttp import ( "context" diff --git a/httpclient/errorclass_test.go b/errorclass_test.go similarity index 64% rename from httpclient/errorclass_test.go rename to errorclass_test.go index 7c87f6a..331a4a4 100644 --- a/httpclient/errorclass_test.go +++ b/errorclass_test.go @@ -1,4 +1,4 @@ -package httpclient_test +package rhttp_test import ( "context" @@ -7,13 +7,13 @@ import ( "net/url" "testing" - "github.com/oswaldom-code/go-httpclient/httpclient" + "github.com/oswaldom-code/rhttp" ) func TestClassify_DeadlineExceeded(t *testing.T) { - classified := httpclient.Classify(context.DeadlineExceeded) + classified := rhttp.Classify(context.DeadlineExceeded) - if classified.Kind != httpclient.ErrKindTimeout { + if classified.Kind != rhttp.ErrKindTimeout { t.Errorf("expected ErrKindTimeout, got %v", classified.Kind) } if !errors.Is(classified, context.DeadlineExceeded) { @@ -22,9 +22,9 @@ func TestClassify_DeadlineExceeded(t *testing.T) { } func TestClassify_Canceled(t *testing.T) { - classified := httpclient.Classify(context.Canceled) + classified := rhttp.Classify(context.Canceled) - if classified.Kind != httpclient.ErrKindCanceled { + if classified.Kind != rhttp.ErrKindCanceled { t.Errorf("expected ErrKindCanceled, got %v", classified.Kind) } } @@ -34,45 +34,45 @@ func TestClassify_DNSError(t *testing.T) { Err: "no such host", Name: "invalid.example.com", } - classified := httpclient.Classify(dnsErr) + classified := rhttp.Classify(dnsErr) - if classified.Kind != httpclient.ErrKindDNS { + 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 := httpclient.Classify(err) + classified := rhttp.Classify(err) - if classified.Kind != httpclient.ErrKindConnection { + 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 := httpclient.Classify(err) + classified := rhttp.Classify(err) - if classified.Kind != httpclient.ErrKindConnection { + 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 := httpclient.Classify(err) + classified := rhttp.Classify(err) - if classified.Kind != httpclient.ErrKindTLS { + 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 := httpclient.Classify(err) + classified := rhttp.Classify(err) - if classified.Kind != httpclient.ErrKindTLS { + if classified.Kind != rhttp.ErrKindTLS { t.Errorf("expected ErrKindTLS, got %v", classified.Kind) } } @@ -83,9 +83,9 @@ func TestClassify_WrappedURLError(t *testing.T) { URL: "http://example.com", Err: context.DeadlineExceeded, } - classified := httpclient.Classify(urlErr) + classified := rhttp.Classify(urlErr) - if classified.Kind != httpclient.ErrKindTimeout { + if classified.Kind != rhttp.ErrKindTimeout { t.Errorf("expected ErrKindTimeout for wrapped deadline, got %v", classified.Kind) } } @@ -96,9 +96,9 @@ func TestClassify_URLErrorTimeout(t *testing.T) { URL: "http://example.com", Err: &timeoutError{}, } - classified := httpclient.Classify(urlErr) + classified := rhttp.Classify(urlErr) - if classified.Kind != httpclient.ErrKindTimeout { + if classified.Kind != rhttp.ErrKindTimeout { t.Errorf("expected ErrKindTimeout, got %v", classified.Kind) } } @@ -111,7 +111,7 @@ func (e *timeoutError) Timeout() bool { return true } func (e *timeoutError) Temporary() bool { return true } func TestClassify_NilError(t *testing.T) { - classified := httpclient.Classify(nil) + classified := rhttp.Classify(nil) if classified != nil { t.Error("expected nil for nil error") @@ -120,16 +120,16 @@ func TestClassify_NilError(t *testing.T) { func TestClassify_UnknownError(t *testing.T) { err := errors.New("something completely unexpected") - classified := httpclient.Classify(err) + classified := rhttp.Classify(err) - if classified.Kind != httpclient.ErrKindUnknown { + 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 := httpclient.Classify(err) + classified := rhttp.Classify(err) expected := "connection: connection refused" if classified.Error() != expected { @@ -139,7 +139,7 @@ func TestClassifiedError_Error(t *testing.T) { func TestClassifiedError_Unwrap(t *testing.T) { originalErr := errors.New("original error") - classified := httpclient.Classify(originalErr) + classified := rhttp.Classify(originalErr) if !errors.Is(classified, originalErr) { t.Error("errors.Is should match original error") @@ -148,16 +148,16 @@ func TestClassifiedError_Unwrap(t *testing.T) { func TestErrorKind_String(t *testing.T) { tests := []struct { - kind httpclient.ErrorKind + kind rhttp.ErrorKind expected string }{ - {httpclient.ErrKindTimeout, "timeout"}, - {httpclient.ErrKindCanceled, "canceled"}, - {httpclient.ErrKindConnection, "connection"}, - {httpclient.ErrKindDNS, "dns"}, - {httpclient.ErrKindTLS, "tls"}, - {httpclient.ErrKindTemporary, "temporary"}, - {httpclient.ErrKindUnknown, "unknown"}, + {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 { @@ -168,11 +168,11 @@ func TestErrorKind_String(t *testing.T) { } func TestErrorKind_IsRetryable(t *testing.T) { - retryable := []httpclient.ErrorKind{ - httpclient.ErrKindTimeout, - httpclient.ErrKindConnection, - httpclient.ErrKindDNS, - httpclient.ErrKindTemporary, + retryable := []rhttp.ErrorKind{ + rhttp.ErrKindTimeout, + rhttp.ErrKindConnection, + rhttp.ErrKindDNS, + rhttp.ErrKindTemporary, } for _, k := range retryable { if !k.IsRetryable() { @@ -180,10 +180,10 @@ func TestErrorKind_IsRetryable(t *testing.T) { } } - notRetryable := []httpclient.ErrorKind{ - httpclient.ErrKindCanceled, - httpclient.ErrKindTLS, - httpclient.ErrKindUnknown, + notRetryable := []rhttp.ErrorKind{ + rhttp.ErrKindCanceled, + rhttp.ErrKindTLS, + rhttp.ErrKindUnknown, } for _, k := range notRetryable { if k.IsRetryable() { @@ -193,76 +193,76 @@ func TestErrorKind_IsRetryable(t *testing.T) { } func TestIsTimeout(t *testing.T) { - if !httpclient.IsTimeout(context.DeadlineExceeded) { + if !rhttp.IsTimeout(context.DeadlineExceeded) { t.Error("expected IsTimeout to be true for DeadlineExceeded") } - if httpclient.IsTimeout(context.Canceled) { + if rhttp.IsTimeout(context.Canceled) { t.Error("expected IsTimeout to be false for Canceled") } - if httpclient.IsTimeout(nil) { + if rhttp.IsTimeout(nil) { t.Error("expected IsTimeout to be false for nil") } } func TestIsCanceled(t *testing.T) { - if !httpclient.IsCanceled(context.Canceled) { + if !rhttp.IsCanceled(context.Canceled) { t.Error("expected IsCanceled to be true for Canceled") } - if httpclient.IsCanceled(context.DeadlineExceeded) { + if rhttp.IsCanceled(context.DeadlineExceeded) { t.Error("expected IsCanceled to be false for DeadlineExceeded") } - if httpclient.IsCanceled(nil) { + if rhttp.IsCanceled(nil) { t.Error("expected IsCanceled to be false for nil") } } func TestIsConnection(t *testing.T) { err := errors.New("connection refused") - if !httpclient.IsConnection(err) { + if !rhttp.IsConnection(err) { t.Error("expected IsConnection to be true for connection refused") } - if httpclient.IsConnection(context.Canceled) { + 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 !httpclient.IsDNS(dnsErr) { + if !rhttp.IsDNS(dnsErr) { t.Error("expected IsDNS to be true for DNSError") } - if httpclient.IsDNS(context.Canceled) { + 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 !httpclient.IsTLS(err) { + if !rhttp.IsTLS(err) { t.Error("expected IsTLS to be true for TLS error") } - if httpclient.IsTLS(context.Canceled) { + if rhttp.IsTLS(context.Canceled) { t.Error("expected IsTLS to be false for Canceled") } } func TestIsRetryable(t *testing.T) { // Retryable - if !httpclient.IsRetryable(context.DeadlineExceeded) { + if !rhttp.IsRetryable(context.DeadlineExceeded) { t.Error("expected timeout to be retryable") } - if !httpclient.IsRetryable(errors.New("connection refused")) { + if !rhttp.IsRetryable(errors.New("connection refused")) { t.Error("expected connection error to be retryable") } // Not retryable - if httpclient.IsRetryable(context.Canceled) { + if rhttp.IsRetryable(context.Canceled) { t.Error("expected canceled to not be retryable") } - if httpclient.IsRetryable(errors.New("tls: certificate error")) { + if rhttp.IsRetryable(errors.New("tls: certificate error")) { t.Error("expected TLS error to not be retryable") } - if httpclient.IsRetryable(nil) { + if rhttp.IsRetryable(nil) { t.Error("expected nil to not be retryable") } } diff --git a/httpclient/errors.go b/errors.go similarity index 54% rename from httpclient/errors.go rename to errors.go index b1f3450..45d9828 100644 --- a/httpclient/errors.go +++ b/errors.go @@ -1,14 +1,14 @@ -package httpclient +package rhttp import "errors" var ( // ErrInvalidRequest is returned when a nil request is passed to Do. - ErrInvalidRequest = errors.New("httpclient: invalid request") + ErrInvalidRequest = errors.New("rhttp: invalid request") // ErrCircuitOpen is returned when the circuit breaker is open. - ErrCircuitOpen = errors.New("httpclient: 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("httpclient: rate limit exceeded") + ErrRateLimited = errors.New("rhttp: rate limit exceeded") ) diff --git a/httpclient/example_test.go b/example_test.go similarity index 71% rename from httpclient/example_test.go rename to example_test.go index 4406e08..27e08e5 100644 --- a/httpclient/example_test.go +++ b/example_test.go @@ -1,4 +1,4 @@ -package httpclient_test +package rhttp_test import ( "context" @@ -6,12 +6,12 @@ import ( "net/http" "time" - "github.com/oswaldom-code/go-httpclient/httpclient" + "github.com/oswaldom-code/rhttp" ) func ExampleNew() { // Create a basic client with default settings - client := httpclient.New() + client := rhttp.New() req, _ := http.NewRequest("GET", "https://api.example.com/users", http.NoBody) resp, err := client.Do(context.Background(), req) @@ -26,14 +26,14 @@ func ExampleNew() { func ExampleNew_withMiddleware() { // Create a client with timeout, retry, and circuit breaker - client := httpclient.New( - httpclient.WithMiddleware( - httpclient.Timeout(5*time.Second), - httpclient.Retry(httpclient.RetryConfig{ + client := rhttp.New( + rhttp.WithMiddleware( + rhttp.Timeout(5*time.Second), + rhttp.Retry(rhttp.RetryConfig{ MaxAttempts: 3, - Backoff: httpclient.ExponentialBackoff(100*time.Millisecond, 5*time.Second), + Backoff: rhttp.ExponentialBackoff(100*time.Millisecond, 5*time.Second), }), - httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ FailureThreshold: 5, ResetTimeout: 30 * time.Second, }), @@ -52,10 +52,10 @@ func ExampleNew_withMiddleware() { } func ExampleR() { - client := httpclient.New() + client := rhttp.New() // Use the fluent API to build and execute requests - resp, err := httpclient.R(client). + resp, err := rhttp.R(client). SetHeader("Authorization", "Bearer token"). SetQueryParam("page", "1"). Get("https://api.example.com/users") @@ -70,7 +70,7 @@ func ExampleR() { } func ExampleRequestBuilder_SetBodyJSON() { - client := httpclient.New() + client := rhttp.New() type User struct { Name string `json:"name"` @@ -79,7 +79,7 @@ func ExampleRequestBuilder_SetBodyJSON() { user := User{Name: "John", Email: "john@example.com"} - resp, err := httpclient.R(client). + resp, err := rhttp.R(client). SetBodyJSON(user). Post("https://api.example.com/users") @@ -93,10 +93,10 @@ func ExampleRequestBuilder_SetBodyJSON() { } func ExampleRequestBuilder_SetPathParam() { - client := httpclient.New() + client := rhttp.New() // Path parameters are replaced in the URL template - resp, err := httpclient.R(client). + resp, err := rhttp.R(client). SetPathParam("id", "123"). Get("https://api.example.com/users/{id}") @@ -111,9 +111,9 @@ func ExampleRequestBuilder_SetPathParam() { } func ExampleClassify() { - client := httpclient.New( - httpclient.WithMiddleware( - httpclient.Timeout(100 * time.Millisecond), + client := rhttp.New( + rhttp.WithMiddleware( + rhttp.Timeout(100 * time.Millisecond), ), ) @@ -121,14 +121,14 @@ func ExampleClassify() { _, err := client.Do(context.Background(), req) if err != nil { - classified := httpclient.Classify(err) + classified := rhttp.Classify(err) fmt.Printf("Error kind: %s, Retryable: %v\n", classified.Kind, classified.Kind.IsRetryable()) } } func ExampleExponentialBackoff() { - backoff := httpclient.ExponentialBackoff(100*time.Millisecond, 10*time.Second) + backoff := rhttp.ExponentialBackoff(100*time.Millisecond, 10*time.Second) // Backoff durations increase exponentially with jitter fmt.Println("Attempt 0:", backoff(0)) // ~100ms @@ -138,12 +138,12 @@ func ExampleExponentialBackoff() { func ExampleNewTokenBucket() { // Allow 10 requests per second with burst of 5 - limiter := httpclient.NewTokenBucket(10, 5) + limiter := rhttp.NewTokenBucket(10, 5) // Use with rate limit middleware - client := httpclient.New( - httpclient.WithMiddleware( - httpclient.RateLimit(httpclient.RateLimitConfig{ + client := rhttp.New( + rhttp.WithMiddleware( + rhttp.RateLimit(rhttp.RateLimitConfig{ Limiter: limiter, WaitOnLimit: true, }), @@ -156,9 +156,9 @@ func ExampleNewTokenBucket() { func ExampleCircuitBreaker() { // Circuit breaker opens after 5 failures // and stays open for 30 seconds before trying again - client := httpclient.New( - httpclient.WithMiddleware( - httpclient.CircuitBreaker(httpclient.CircuitBreakerConfig{ + client := rhttp.New( + rhttp.WithMiddleware( + rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{ FailureThreshold: 5, ResetTimeout: 30 * time.Second, }), @@ -170,14 +170,14 @@ func ExampleCircuitBreaker() { func ExampleLogging() { // Custom logger that prints request/response details - logger := httpclient.LoggerFunc(func(entry httpclient.LogEntry) { + logger := rhttp.LoggerFunc(func(entry rhttp.LogEntry) { fmt.Printf("%s %s -> %d (%s)\n", entry.Method, entry.URL, entry.StatusCode, entry.Duration) }) - client := httpclient.New( - httpclient.WithMiddleware( - httpclient.Logging(httpclient.LoggingConfig{ + client := rhttp.New( + rhttp.WithMiddleware( + rhttp.Logging(rhttp.LoggingConfig{ Logger: logger, }), ), @@ -188,11 +188,11 @@ func ExampleLogging() { func ExampleGetBuffer() { // Get a buffer from the pool - buf := httpclient.GetBuffer() + buf := rhttp.GetBuffer() // Use the buffer buf.WriteString("Hello, World!") // Return to pool when done - httpclient.PutBuffer(buf) + 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 index 0ae54c0..363c9bd 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module github.com/oswaldom-code/go-httpclient +module github.com/oswaldom-code/rhttp go 1.21 diff --git a/httpclient/internal/roundtripper.go b/internal/roundtripper.go similarity index 84% rename from httpclient/internal/roundtripper.go rename to internal/roundtripper.go index 4ebed52..0f9465d 100644 --- a/httpclient/internal/roundtripper.go +++ b/internal/roundtripper.go @@ -1,4 +1,4 @@ -// Package internal provides internal utilities for the httpclient package. +// Package internal provides internal utilities for the rhttp package. package internal import "net/http" diff --git a/httpclient/logging.go b/logging.go similarity index 99% rename from httpclient/logging.go rename to logging.go index 52faf59..a6c1184 100644 --- a/httpclient/logging.go +++ b/logging.go @@ -1,4 +1,4 @@ -package httpclient +package rhttp import ( "net/http" diff --git a/httpclient/logging_test.go b/logging_test.go similarity index 76% rename from httpclient/logging_test.go rename to logging_test.go index c112f1b..7fe9bb3 100644 --- a/httpclient/logging_test.go +++ b/logging_test.go @@ -1,4 +1,4 @@ -package httpclient_test +package rhttp_test import ( "context" @@ -8,13 +8,13 @@ import ( "testing" "time" - "github.com/oswaldom-code/go-httpclient/httpclient" - "github.com/oswaldom-code/go-httpclient/httpclient/internal" + "github.com/oswaldom-code/rhttp" + "github.com/oswaldom-code/rhttp/internal" ) func TestLogging_LogsSuccessfulRequest(t *testing.T) { - var captured httpclient.LogEntry - logger := httpclient.LoggerFunc(func(entry httpclient.LogEntry) { + var captured rhttp.LogEntry + logger := rhttp.LoggerFunc(func(entry rhttp.LogEntry) { captured = entry }) @@ -22,9 +22,9 @@ func TestLogging_LogsSuccessfulRequest(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Logging(httpclient.LoggingConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Logging(rhttp.LoggingConfig{ Logger: logger, })), ) @@ -50,8 +50,8 @@ func TestLogging_LogsSuccessfulRequest(t *testing.T) { } func TestLogging_LogsFailedRequest(t *testing.T) { - var captured httpclient.LogEntry - logger := httpclient.LoggerFunc(func(entry httpclient.LogEntry) { + var captured rhttp.LogEntry + logger := rhttp.LoggerFunc(func(entry rhttp.LogEntry) { captured = entry }) @@ -60,9 +60,9 @@ func TestLogging_LogsFailedRequest(t *testing.T) { return nil, expectedErr }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Logging(httpclient.LoggingConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Logging(rhttp.LoggingConfig{ Logger: logger, })), ) @@ -82,8 +82,8 @@ func TestLogging_LogsFailedRequest(t *testing.T) { } func TestLogging_MeasuresDuration(t *testing.T) { - var captured httpclient.LogEntry - logger := httpclient.LoggerFunc(func(entry httpclient.LogEntry) { + var captured rhttp.LogEntry + logger := rhttp.LoggerFunc(func(entry rhttp.LogEntry) { captured = entry }) @@ -92,9 +92,9 @@ func TestLogging_MeasuresDuration(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Logging(httpclient.LoggingConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Logging(rhttp.LoggingConfig{ Logger: logger, })), ) @@ -109,7 +109,7 @@ func TestLogging_MeasuresDuration(t *testing.T) { func TestLogging_ShouldLogFilters(t *testing.T) { var logCount int - logger := httpclient.LoggerFunc(func(entry httpclient.LogEntry) { + logger := rhttp.LoggerFunc(func(entry rhttp.LogEntry) { logCount++ }) @@ -123,9 +123,9 @@ func TestLogging_ShouldLogFilters(t *testing.T) { }) // Only log errors (5xx) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Logging(httpclient.LoggingConfig{ + 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) @@ -151,9 +151,9 @@ func TestLogging_NilLoggerIsNoOp(t *testing.T) { }) // Should not panic with nil logger - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Logging(httpclient.LoggingConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Logging(rhttp.LoggingConfig{ Logger: nil, })), ) @@ -171,8 +171,8 @@ func TestLogging_NilLoggerIsNoOp(t *testing.T) { func TestLogging_ThreadSafety(t *testing.T) { var mu sync.Mutex - var entries []httpclient.LogEntry - logger := httpclient.LoggerFunc(func(entry httpclient.LogEntry) { + var entries []rhttp.LogEntry + logger := rhttp.LoggerFunc(func(entry rhttp.LogEntry) { mu.Lock() entries = append(entries, entry) mu.Unlock() @@ -182,9 +182,9 @@ func TestLogging_ThreadSafety(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Logging(httpclient.LoggingConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Logging(rhttp.LoggingConfig{ Logger: logger, })), ) diff --git a/httpclient/metrics.go b/metrics.go similarity index 99% rename from httpclient/metrics.go rename to metrics.go index 9a1dcd8..ad72ad4 100644 --- a/httpclient/metrics.go +++ b/metrics.go @@ -1,4 +1,4 @@ -package httpclient +package rhttp import ( "net/http" diff --git a/httpclient/metrics_test.go b/metrics_test.go similarity index 73% rename from httpclient/metrics_test.go rename to metrics_test.go index aeef2c1..89130df 100644 --- a/httpclient/metrics_test.go +++ b/metrics_test.go @@ -1,4 +1,4 @@ -package httpclient_test +package rhttp_test import ( "bytes" @@ -9,13 +9,13 @@ import ( "testing" "time" - "github.com/oswaldom-code/go-httpclient/httpclient" - "github.com/oswaldom-code/go-httpclient/httpclient/internal" + "github.com/oswaldom-code/rhttp" + "github.com/oswaldom-code/rhttp/internal" ) func TestMetrics_RecordsSuccessfulRequest(t *testing.T) { - var captured httpclient.MetricEvent - recorder := httpclient.MetricsRecorderFunc(func(event httpclient.MetricEvent) { + var captured rhttp.MetricEvent + recorder := rhttp.MetricsRecorderFunc(func(event rhttp.MetricEvent) { captured = event }) @@ -27,9 +27,9 @@ func TestMetrics_RecordsSuccessfulRequest(t *testing.T) { }, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Metrics(httpclient.MetricsConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Metrics(rhttp.MetricsConfig{ Recorder: recorder, })), ) @@ -64,8 +64,8 @@ func TestMetrics_RecordsSuccessfulRequest(t *testing.T) { } func TestMetrics_RecordsFailedRequest(t *testing.T) { - var captured httpclient.MetricEvent - recorder := httpclient.MetricsRecorderFunc(func(event httpclient.MetricEvent) { + var captured rhttp.MetricEvent + recorder := rhttp.MetricsRecorderFunc(func(event rhttp.MetricEvent) { captured = event }) @@ -74,9 +74,9 @@ func TestMetrics_RecordsFailedRequest(t *testing.T) { return nil, expectedErr }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Metrics(httpclient.MetricsConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Metrics(rhttp.MetricsConfig{ Recorder: recorder, })), ) @@ -96,8 +96,8 @@ func TestMetrics_RecordsFailedRequest(t *testing.T) { } func TestMetrics_5xxIsNotSuccess(t *testing.T) { - var captured httpclient.MetricEvent - recorder := httpclient.MetricsRecorderFunc(func(event httpclient.MetricEvent) { + var captured rhttp.MetricEvent + recorder := rhttp.MetricsRecorderFunc(func(event rhttp.MetricEvent) { captured = event }) @@ -105,9 +105,9 @@ func TestMetrics_5xxIsNotSuccess(t *testing.T) { return &http.Response{StatusCode: http.StatusInternalServerError, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Metrics(httpclient.MetricsConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Metrics(rhttp.MetricsConfig{ Recorder: recorder, })), ) @@ -124,8 +124,8 @@ func TestMetrics_5xxIsNotSuccess(t *testing.T) { } func TestMetrics_4xxIsSuccess(t *testing.T) { - var captured httpclient.MetricEvent - recorder := httpclient.MetricsRecorderFunc(func(event httpclient.MetricEvent) { + var captured rhttp.MetricEvent + recorder := rhttp.MetricsRecorderFunc(func(event rhttp.MetricEvent) { captured = event }) @@ -133,9 +133,9 @@ func TestMetrics_4xxIsSuccess(t *testing.T) { return &http.Response{StatusCode: http.StatusNotFound, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Metrics(httpclient.MetricsConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Metrics(rhttp.MetricsConfig{ Recorder: recorder, })), ) @@ -154,9 +154,9 @@ func TestMetrics_NilRecorderIsNoOp(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Metrics(httpclient.MetricsConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Metrics(rhttp.MetricsConfig{ Recorder: nil, })), ) @@ -173,8 +173,8 @@ func TestMetrics_NilRecorderIsNoOp(t *testing.T) { } func TestMetrics_RecordsBytesSent(t *testing.T) { - var captured httpclient.MetricEvent - recorder := httpclient.MetricsRecorderFunc(func(event httpclient.MetricEvent) { + var captured rhttp.MetricEvent + recorder := rhttp.MetricsRecorderFunc(func(event rhttp.MetricEvent) { captured = event }) @@ -182,9 +182,9 @@ func TestMetrics_RecordsBytesSent(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Metrics(httpclient.MetricsConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Metrics(rhttp.MetricsConfig{ Recorder: recorder, })), ) @@ -200,8 +200,8 @@ func TestMetrics_RecordsBytesSent(t *testing.T) { } func TestMetrics_MeasuresDuration(t *testing.T) { - var captured httpclient.MetricEvent - recorder := httpclient.MetricsRecorderFunc(func(event httpclient.MetricEvent) { + var captured rhttp.MetricEvent + recorder := rhttp.MetricsRecorderFunc(func(event rhttp.MetricEvent) { captured = event }) @@ -210,9 +210,9 @@ func TestMetrics_MeasuresDuration(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Metrics(httpclient.MetricsConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Metrics(rhttp.MetricsConfig{ Recorder: recorder, })), ) @@ -227,8 +227,8 @@ func TestMetrics_MeasuresDuration(t *testing.T) { func TestMetrics_ThreadSafety(t *testing.T) { var mu sync.Mutex - var events []httpclient.MetricEvent - recorder := httpclient.MetricsRecorderFunc(func(event httpclient.MetricEvent) { + var events []rhttp.MetricEvent + recorder := rhttp.MetricsRecorderFunc(func(event rhttp.MetricEvent) { mu.Lock() events = append(events, event) mu.Unlock() @@ -238,9 +238,9 @@ func TestMetrics_ThreadSafety(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Metrics(httpclient.MetricsConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Metrics(rhttp.MetricsConfig{ Recorder: recorder, })), ) diff --git a/httpclient/middleware.go b/middleware.go similarity index 95% rename from httpclient/middleware.go rename to middleware.go index 98f47c6..cf9cd79 100644 --- a/httpclient/middleware.go +++ b/middleware.go @@ -1,4 +1,4 @@ -package httpclient +package rhttp import "net/http" diff --git a/httpclient/options.go b/options.go similarity index 96% rename from httpclient/options.go rename to options.go index 8cefbf9..96f46f2 100644 --- a/httpclient/options.go +++ b/options.go @@ -1,4 +1,4 @@ -package httpclient +package rhttp import "net/http" diff --git a/httpclient/pool.go b/pool.go similarity index 99% rename from httpclient/pool.go rename to pool.go index 1f74682..5ec977e 100644 --- a/httpclient/pool.go +++ b/pool.go @@ -1,4 +1,4 @@ -package httpclient +package rhttp import ( "bytes" diff --git a/httpclient/pool_test.go b/pool_test.go similarity index 81% rename from httpclient/pool_test.go rename to pool_test.go index 6f6fb0d..39f2014 100644 --- a/httpclient/pool_test.go +++ b/pool_test.go @@ -1,14 +1,14 @@ -package httpclient_test +package rhttp_test import ( "sync" "testing" - "github.com/oswaldom-code/go-httpclient/httpclient" + "github.com/oswaldom-code/rhttp" ) func TestBufferPool_GetAndPut(t *testing.T) { - buf := httpclient.GetBuffer() + buf := rhttp.GetBuffer() if buf == nil { t.Fatal("expected non-nil buffer") } @@ -18,19 +18,19 @@ func TestBufferPool_GetAndPut(t *testing.T) { t.Errorf("expected length 9, got %d", buf.Len()) } - httpclient.PutBuffer(buf) + rhttp.PutBuffer(buf) // Get another buffer - should be reset - buf2 := httpclient.GetBuffer() + buf2 := rhttp.GetBuffer() if buf2.Len() != 0 { t.Errorf("expected reset buffer with length 0, got %d", buf2.Len()) } - httpclient.PutBuffer(buf2) + rhttp.PutBuffer(buf2) } func TestBufferPool_NilSafe(_ *testing.T) { // Should not panic - httpclient.PutBuffer(nil) + rhttp.PutBuffer(nil) } func TestBufferPool_Concurrent(_ *testing.T) { @@ -39,9 +39,9 @@ func TestBufferPool_Concurrent(_ *testing.T) { wg.Add(1) go func() { defer wg.Done() - buf := httpclient.GetBuffer() + buf := rhttp.GetBuffer() buf.WriteString("concurrent test") - httpclient.PutBuffer(buf) + rhttp.PutBuffer(buf) }() } wg.Wait() @@ -62,7 +62,7 @@ func TestResponse_IsSuccess(t *testing.T) { } for _, tt := range tests { - r := &httpclient.Response{StatusCode: tt.status} + r := &rhttp.Response{StatusCode: tt.status} if r.IsSuccess() != tt.expected { t.Errorf("IsSuccess(%d) = %v, want %v", tt.status, r.IsSuccess(), tt.expected) } @@ -83,7 +83,7 @@ func TestResponse_IsError(t *testing.T) { } for _, tt := range tests { - r := &httpclient.Response{StatusCode: tt.status} + r := &rhttp.Response{StatusCode: tt.status} if r.IsError() != tt.expected { t.Errorf("IsError(%d) = %v, want %v", tt.status, r.IsError(), tt.expected) } @@ -102,7 +102,7 @@ func TestResponse_IsServerError(t *testing.T) { } for _, tt := range tests { - r := &httpclient.Response{StatusCode: tt.status} + r := &rhttp.Response{StatusCode: tt.status} if r.IsServerError() != tt.expected { t.Errorf("IsServerError(%d) = %v, want %v", tt.status, r.IsServerError(), tt.expected) } @@ -122,7 +122,7 @@ func TestResponse_IsClientError(t *testing.T) { } for _, tt := range tests { - r := &httpclient.Response{StatusCode: tt.status} + r := &rhttp.Response{StatusCode: tt.status} if r.IsClientError() != tt.expected { t.Errorf("IsClientError(%d) = %v, want %v", tt.status, r.IsClientError(), tt.expected) } @@ -132,9 +132,9 @@ func TestResponse_IsClientError(t *testing.T) { func BenchmarkBufferPool(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { - buf := httpclient.GetBuffer() + buf := rhttp.GetBuffer() buf.WriteString("benchmark test data") - httpclient.PutBuffer(buf) + rhttp.PutBuffer(buf) } } diff --git a/httpclient/ratelimit.go b/ratelimit.go similarity index 99% rename from httpclient/ratelimit.go rename to ratelimit.go index 1e97083..c3efa46 100644 --- a/httpclient/ratelimit.go +++ b/ratelimit.go @@ -1,4 +1,4 @@ -package httpclient +package rhttp import ( "context" diff --git a/httpclient/ratelimit_test.go b/ratelimit_test.go similarity index 81% rename from httpclient/ratelimit_test.go rename to ratelimit_test.go index b70e493..704a9de 100644 --- a/httpclient/ratelimit_test.go +++ b/ratelimit_test.go @@ -1,4 +1,4 @@ -package httpclient_test +package rhttp_test import ( "context" @@ -9,12 +9,12 @@ import ( "testing" "time" - "github.com/oswaldom-code/go-httpclient/httpclient" - "github.com/oswaldom-code/go-httpclient/httpclient/internal" + "github.com/oswaldom-code/rhttp" + "github.com/oswaldom-code/rhttp/internal" ) func TestTokenBucket_Basic(t *testing.T) { - tb := httpclient.NewTokenBucket(10, 5) // 10 req/s, burst of 5 + 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++ { @@ -30,7 +30,7 @@ func TestTokenBucket_Basic(t *testing.T) { } func TestTokenBucket_Refill(t *testing.T) { - tb := httpclient.NewTokenBucket(100, 1) // 100 req/s, burst of 1 + tb := rhttp.NewTokenBucket(100, 1) // 100 req/s, burst of 1 // Consume the token if !tb.TryAcquire() { @@ -52,7 +52,7 @@ func TestTokenBucket_Refill(t *testing.T) { } func TestTokenBucket_Wait(t *testing.T) { - tb := httpclient.NewTokenBucket(100, 1) // 100 req/s, burst of 1 + tb := rhttp.NewTokenBucket(100, 1) // 100 req/s, burst of 1 // Consume the token tb.TryAcquire() @@ -72,7 +72,7 @@ func TestTokenBucket_Wait(t *testing.T) { } func TestTokenBucket_Concurrent(t *testing.T) { - tb := httpclient.NewTokenBucket(1000, 100) + tb := rhttp.NewTokenBucket(1000, 100) var acquired int64 var wg sync.WaitGroup @@ -101,10 +101,10 @@ func TestRateLimit_Middleware(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - limiter := httpclient.NewTokenBucket(1000, 10) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.RateLimit(httpclient.RateLimitConfig{ + limiter := rhttp.NewTokenBucket(1000, 10) + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.RateLimit(rhttp.RateLimitConfig{ Limiter: limiter, WaitOnLimit: true, })), @@ -129,10 +129,10 @@ func TestRateLimit_NoWait(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - limiter := httpclient.NewTokenBucket(1, 1) // 1 req/s, burst of 1 - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.RateLimit(httpclient.RateLimitConfig{ + 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, })), @@ -148,7 +148,7 @@ func TestRateLimit_NoWait(t *testing.T) { // Second should fail immediately req, _ = http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) _, err = c.Do(context.Background(), req) - if !errors.Is(err, httpclient.ErrRateLimited) { + if !errors.Is(err, rhttp.ErrRateLimited) { t.Fatalf("expected ErrRateLimited, got %v", err) } } @@ -169,10 +169,10 @@ func TestRateLimit_RespectRetryAfter(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - limiter := httpclient.NewTokenBucket(1000, 100) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.RateLimit(httpclient.RateLimitConfig{ + limiter := rhttp.NewTokenBucket(1000, 100) + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.RateLimit(rhttp.RateLimitConfig{ Limiter: limiter, WaitOnLimit: true, RespectRetryAfter: true, @@ -207,9 +207,9 @@ func TestRateLimit_NilLimiter(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.RateLimit(httpclient.RateLimitConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.RateLimit(rhttp.RateLimitConfig{ Limiter: nil, })), ) @@ -226,7 +226,7 @@ func TestRateLimit_NilLimiter(t *testing.T) { } func TestPerHostRateLimiter(t *testing.T) { - phl := httpclient.NewPerHostRateLimiter(10, 5) + phl := rhttp.NewPerHostRateLimiter(10, 5) limiter1 := phl.GetLimiter("api.example.com") limiter2 := phl.GetLimiter("api.other.com") @@ -257,7 +257,7 @@ func TestPerHostRateLimiter(t *testing.T) { } func BenchmarkTokenBucket_TryAcquire(b *testing.B) { - tb := httpclient.NewTokenBucket(1000000, 1000000) // high limits + tb := rhttp.NewTokenBucket(1000000, 1000000) // high limits b.ResetTimer() b.ReportAllocs() @@ -268,7 +268,7 @@ func BenchmarkTokenBucket_TryAcquire(b *testing.B) { } func BenchmarkTokenBucket_Concurrent(b *testing.B) { - tb := httpclient.NewTokenBucket(1000000, 1000000) + tb := rhttp.NewTokenBucket(1000000, 1000000) b.ResetTimer() b.ReportAllocs() diff --git a/httpclient/request.go b/request.go similarity index 99% rename from httpclient/request.go rename to request.go index 45090a4..0c5a9e7 100644 --- a/httpclient/request.go +++ b/request.go @@ -1,4 +1,4 @@ -package httpclient +package rhttp import ( "bytes" diff --git a/httpclient/request_test.go b/request_test.go similarity index 81% rename from httpclient/request_test.go rename to request_test.go index eda62bd..43a4a9d 100644 --- a/httpclient/request_test.go +++ b/request_test.go @@ -1,4 +1,4 @@ -package httpclient_test +package rhttp_test import ( "context" @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/oswaldom-code/go-httpclient/httpclient" - "github.com/oswaldom-code/go-httpclient/httpclient/internal" + "github.com/oswaldom-code/rhttp" + "github.com/oswaldom-code/rhttp/internal" ) func TestRequestBuilder_Get(t *testing.T) { @@ -20,9 +20,9 @@ func TestRequestBuilder_Get(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New(httpclient.WithTransport(rt)) + c := rhttp.New(rhttp.WithTransport(rt)) - resp, err := httpclient.R(c).Get("http://example.com/api") + resp, err := rhttp.R(c).Get("http://example.com/api") if err != nil { t.Fatalf("unexpected error: %v", err) @@ -45,9 +45,9 @@ func TestRequestBuilder_Post(t *testing.T) { return &http.Response{StatusCode: http.StatusCreated, Request: req}, nil }) - c := httpclient.New(httpclient.WithTransport(rt)) + c := rhttp.New(rhttp.WithTransport(rt)) - resp, err := httpclient.R(c). + resp, err := rhttp.R(c). SetBodyString("test body"). Post("http://example.com/api") @@ -69,9 +69,9 @@ func TestRequestBuilder_Headers(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New(httpclient.WithTransport(rt)) + c := rhttp.New(rhttp.WithTransport(rt)) - _, _ = httpclient.R(c). + _, _ = rhttp.R(c). SetHeader("X-Custom", "value1"). SetHeaders(map[string]string{ "X-Another": "value2", @@ -113,9 +113,9 @@ func TestRequestBuilder_QueryParams(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New(httpclient.WithTransport(rt)) + c := rhttp.New(rhttp.WithTransport(rt)) - _, _ = httpclient.R(c). + _, _ = rhttp.R(c). SetQueryParam("page", "1"). SetQueryParams(map[string]string{ "limit": "10", @@ -149,9 +149,9 @@ func TestRequestBuilder_PathParams(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New(httpclient.WithTransport(rt)) + c := rhttp.New(rhttp.WithTransport(rt)) - _, _ = httpclient.R(c). + _, _ = rhttp.R(c). SetPathParam("org", "acme"). SetPathParams(map[string]string{ "repo": "api", @@ -174,10 +174,10 @@ func TestRequestBuilder_SetBodyJSON(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New(httpclient.WithTransport(rt)) + c := rhttp.New(rhttp.WithTransport(rt)) payload := map[string]string{"name": "test", "value": "123"} - _, _ = httpclient.R(c). + _, _ = rhttp.R(c). SetBodyJSON(payload). Post("http://example.com/api") @@ -204,9 +204,9 @@ func TestRequestBuilder_SetBodyForm(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New(httpclient.WithTransport(rt)) + c := rhttp.New(rhttp.WithTransport(rt)) - _, _ = httpclient.R(c). + _, _ = rhttp.R(c). SetBodyForm(map[string]string{ "username": "test", "password": "secret", @@ -232,9 +232,9 @@ func TestRequestBuilder_SetAuthToken(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New(httpclient.WithTransport(rt)) + c := rhttp.New(rhttp.WithTransport(rt)) - _, _ = httpclient.R(c). + _, _ = rhttp.R(c). SetAuthToken("my-token-123"). Get("http://example.com/api") @@ -251,9 +251,9 @@ func TestRequestBuilder_SetBasicAuth(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New(httpclient.WithTransport(rt)) + c := rhttp.New(rhttp.WithTransport(rt)) - _, _ = httpclient.R(c). + _, _ = rhttp.R(c). SetBasicAuth("user", "pass"). Get("http://example.com/api") @@ -274,9 +274,9 @@ func TestRequestBuilder_Timeout(t *testing.T) { } }) - c := httpclient.New(httpclient.WithTransport(rt)) + c := rhttp.New(rhttp.WithTransport(rt)) - _, err := httpclient.R(c). + _, err := rhttp.R(c). SetTimeout(50 * time.Millisecond). Get("http://example.com/api") @@ -299,12 +299,12 @@ func TestRequestBuilder_Context(t *testing.T) { } }) - c := httpclient.New(httpclient.WithTransport(rt)) + c := rhttp.New(rhttp.WithTransport(rt)) ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() - _, err := httpclient.R(c). + _, err := rhttp.R(c). Context(ctx). Get("http://example.com/api") @@ -316,16 +316,16 @@ func TestRequestBuilder_Context(t *testing.T) { func TestRequestBuilder_AllMethods(t *testing.T) { methods := []struct { name string - fn func(*httpclient.RequestBuilder, string) (*http.Response, error) + fn func(*rhttp.RequestBuilder, string) (*http.Response, error) expect string }{ - {"Get", func(rb *httpclient.RequestBuilder, url string) (*http.Response, error) { return rb.Get(url) }, "GET"}, - {"Post", func(rb *httpclient.RequestBuilder, url string) (*http.Response, error) { return rb.Post(url) }, "POST"}, - {"Put", func(rb *httpclient.RequestBuilder, url string) (*http.Response, error) { return rb.Put(url) }, "PUT"}, - {"Patch", func(rb *httpclient.RequestBuilder, url string) (*http.Response, error) { return rb.Patch(url) }, "PATCH"}, - {"Delete", func(rb *httpclient.RequestBuilder, url string) (*http.Response, error) { return rb.Delete(url) }, "DELETE"}, - {"Head", func(rb *httpclient.RequestBuilder, url string) (*http.Response, error) { return rb.Head(url) }, "HEAD"}, - {"Options", func(rb *httpclient.RequestBuilder, url string) (*http.Response, error) { return rb.Options(url) }, "OPTIONS"}, + {"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 { @@ -336,8 +336,8 @@ func TestRequestBuilder_AllMethods(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New(httpclient.WithTransport(rt)) - _, _ = m.fn(httpclient.R(c), "http://example.com") + 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) @@ -351,13 +351,13 @@ func BenchmarkRequestBuilder_Simple(b *testing.B) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New(httpclient.WithTransport(rt)) + c := rhttp.New(rhttp.WithTransport(rt)) b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { - _, _ = httpclient.R(c).Get("http://example.com") + _, _ = rhttp.R(c).Get("http://example.com") } } @@ -366,13 +366,13 @@ func BenchmarkRequestBuilder_WithOptions(b *testing.B) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New(httpclient.WithTransport(rt)) + c := rhttp.New(rhttp.WithTransport(rt)) b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { - _, _ = httpclient.R(c). + _, _ = rhttp.R(c). SetHeader("Authorization", "Bearer token"). SetQueryParam("page", "1"). SetPathParam("id", "123"). diff --git a/httpclient/retry.go b/retry.go similarity index 99% rename from httpclient/retry.go rename to retry.go index cb7d8ab..d533ad6 100644 --- a/httpclient/retry.go +++ b/retry.go @@ -1,4 +1,4 @@ -package httpclient +package rhttp import ( "io" diff --git a/httpclient/retry_test.go b/retry_test.go similarity index 85% rename from httpclient/retry_test.go rename to retry_test.go index 9371f66..1265481 100644 --- a/httpclient/retry_test.go +++ b/retry_test.go @@ -1,4 +1,4 @@ -package httpclient_test +package rhttp_test import ( "bytes" @@ -11,8 +11,8 @@ import ( "testing" "time" - "github.com/oswaldom-code/go-httpclient/httpclient" - "github.com/oswaldom-code/go-httpclient/httpclient/internal" + "github.com/oswaldom-code/rhttp" + "github.com/oswaldom-code/rhttp/internal" ) func TestRetry_SuccessOnFirstAttempt(t *testing.T) { @@ -22,9 +22,9 @@ func TestRetry_SuccessOnFirstAttempt(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Retry(httpclient.RetryConfig{MaxAttempts: 3})), + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{MaxAttempts: 3})), ) req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) @@ -55,9 +55,9 @@ func TestRetry_SuccessAfterRetry(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Retry(httpclient.RetryConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ MaxAttempts: 3, Backoff: func(int) time.Duration { return time.Millisecond }, })), @@ -85,9 +85,9 @@ func TestRetry_MaxAttemptsExhausted(t *testing.T) { return nil, expectedErr }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Retry(httpclient.RetryConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ MaxAttempts: 3, Backoff: func(int) time.Duration { return time.Millisecond }, })), @@ -111,9 +111,9 @@ func TestRetry_NonIdempotentMethodNotRetried(t *testing.T) { return nil, errors.New("connection refused") }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Retry(httpclient.RetryConfig{MaxAttempts: 3})), + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{MaxAttempts: 3})), ) req, _ := http.NewRequest(http.MethodPost, "http://example.com", http.NoBody) @@ -134,9 +134,9 @@ func TestRetry_NonIdempotentMethodWithRetryAllMethods(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Retry(httpclient.RetryConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ MaxAttempts: 3, RetryAllMethods: true, Backoff: func(int) time.Duration { return time.Millisecond }, @@ -170,9 +170,9 @@ func TestRetry_ContextCancelledDuringBackoff(t *testing.T) { return nil, errors.New("connection refused") }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Retry(httpclient.RetryConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ MaxAttempts: 3, Backoff: func(int) time.Duration { return 10 * time.Second }, })), @@ -215,9 +215,9 @@ func TestRetry_RetryableStatusCodes(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Retry(httpclient.RetryConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ MaxAttempts: 3, Backoff: func(int) time.Duration { return time.Millisecond }, })), @@ -248,9 +248,9 @@ func TestRetry_LastAttemptBodyReadable(t *testing.T) { }, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Retry(httpclient.RetryConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ MaxAttempts: 3, Backoff: func(int) time.Duration { return time.Millisecond }, })), @@ -286,9 +286,9 @@ func TestRetry_NonRetryableStatusCode(t *testing.T) { }, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Retry(httpclient.RetryConfig{MaxAttempts: 3})), + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{MaxAttempts: 3})), ) req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) @@ -318,9 +318,9 @@ func TestRetry_NonReplayableBodyNotRetried(t *testing.T) { return nil, errors.New("connection refused") }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Retry(httpclient.RetryConfig{ + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Retry(rhttp.RetryConfig{ MaxAttempts: 3, Backoff: func(int) time.Duration { return time.Millisecond }, })), @@ -339,7 +339,7 @@ func TestRetry_NonReplayableBodyNotRetried(t *testing.T) { } func TestExponentialBackoff(t *testing.T) { - backoff := httpclient.ExponentialBackoff(100*time.Millisecond, 1*time.Second) + backoff := rhttp.ExponentialBackoff(100*time.Millisecond, 1*time.Second) // Test exponential growth (with some tolerance for jitter) for attempt := 0; attempt < 5; attempt++ { diff --git a/httpclient/timeout.go b/timeout.go similarity index 98% rename from httpclient/timeout.go rename to timeout.go index 28f1114..666e19a 100644 --- a/httpclient/timeout.go +++ b/timeout.go @@ -1,4 +1,4 @@ -package httpclient +package rhttp import ( "context" diff --git a/httpclient/timeout_test.go b/timeout_test.go similarity index 84% rename from httpclient/timeout_test.go rename to timeout_test.go index 20d243f..d4d29e8 100644 --- a/httpclient/timeout_test.go +++ b/timeout_test.go @@ -1,4 +1,4 @@ -package httpclient_test +package rhttp_test import ( "context" @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/oswaldom-code/go-httpclient/httpclient" - "github.com/oswaldom-code/go-httpclient/httpclient/internal" + "github.com/oswaldom-code/rhttp" + "github.com/oswaldom-code/rhttp/internal" ) func TestTimeout_RequestCompletesBeforeTimeout(t *testing.T) { @@ -18,9 +18,9 @@ func TestTimeout_RequestCompletesBeforeTimeout(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Timeout(5*time.Second)), + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Timeout(5*time.Second)), ) req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) @@ -44,9 +44,9 @@ func TestTimeout_RequestExceedsTimeout(t *testing.T) { } }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Timeout(50*time.Millisecond)), + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Timeout(50*time.Millisecond)), ) req, _ := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) @@ -64,9 +64,9 @@ func TestTimeout_RespectsExistingShorterDeadline(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Timeout(10*time.Second)), + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Timeout(10*time.Second)), ) // Context with 100ms deadline (shorter than middleware's 10s) @@ -99,8 +99,8 @@ func TestTimeout_StreamingBodyReadableAfterReturn(t *testing.T) { })) defer srv.Close() - c := httpclient.New( - httpclient.WithMiddleware(httpclient.Timeout(5 * time.Second)), + c := rhttp.New( + rhttp.WithMiddleware(rhttp.Timeout(5 * time.Second)), ) req, _ := http.NewRequest(http.MethodGet, srv.URL, http.NoBody) @@ -126,9 +126,9 @@ func TestTimeout_AppliesWhenExistingDeadlineLonger(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Request: req}, nil }) - c := httpclient.New( - httpclient.WithTransport(rt), - httpclient.WithMiddleware(httpclient.Timeout(100*time.Millisecond)), + c := rhttp.New( + rhttp.WithTransport(rt), + rhttp.WithMiddleware(rhttp.Timeout(100*time.Millisecond)), ) // Context with 10s deadline (longer than middleware's 100ms) diff --git a/httpclient/transport.go b/transport.go similarity index 95% rename from httpclient/transport.go rename to transport.go index f467c5e..fa6b627 100644 --- a/httpclient/transport.go +++ b/transport.go @@ -1,4 +1,4 @@ -package httpclient +package rhttp import ( "net/http" From 2cce947511a7cd80691f4e4244d7d6f6e85e68d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Tue, 21 Jul 2026 20:43:09 +0200 Subject: [PATCH 13/13] ci: reactivate the CI workflow Uncomment .github/workflows/ci.yml so the test, lint, build and benchmark jobs run on pull requests and pushes to main. The Go 1.21 matrix job now passes since backoff no longer imports math/rand/v2. --- .github/workflows/ci.yml | 208 +++++++++++++++++++-------------------- 1 file changed, 104 insertions(+), 104 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b3d349..57312e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,104 +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 +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