diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index e339c66..199677f 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -13,10 +13,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: ./go.mod @@ -80,4 +80,4 @@ jobs: # execute again to get the summary echo "" >> $GITHUB_STEP_SUMMARY echo "### Coverage report" >> $GITHUB_STEP_SUMMARY - go-test-coverage --config=./.testcoverage.yml | sed 's/PASS/PASS ✅/g' | sed 's/FAIL/FAIL ❌/g' | tee -a $GITHUB_STEP_SUMMARY \ No newline at end of file + go-test-coverage --config=./.testcoverage.yml | sed 's/PASS/PASS ✅/g' | sed 's/FAIL/FAIL ❌/g' | tee -a $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 825da00..e6ac920 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,3 @@ - name: Release # https://help.github.com/es/actions/reference/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet @@ -19,11 +18,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Go 1.x id: go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: ./go.mod @@ -71,4 +70,4 @@ jobs: draft: false prerelease: false generate_release_notes: true - make_latest: true \ No newline at end of file + make_latest: true diff --git a/README.md b/README.md index e47234b..2d0b142 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,11 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/p2p-b2b/httpretrier.svg)](https://pkg.go.dev/github.com/p2p-b2b/httpretrier) ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/p2p-b2b/httpretrier?style=plastic) -`httpretrier` is a Go library that provides a convenient way to add automatic retry logic to your HTTP requests. It wraps the standard `http.Client` and `http.Transport` to handle transient server errors (5xx) or network issues by retrying requests based on configurable strategies. +`httpretrier` is a Go library that provides a **transparent** drop-in replacement for `http.Client` with automatic retry logic. It preserves all existing request headers (including authentication) while handling transient server errors (5xx) or network issues by retrying requests based on configurable strategies. ## Features +* **Transparent by Default:** Works as a zero-configuration drop-in replacement for `http.Client`, automatically preserving existing authentication tokens, custom headers, and all request properties without any code changes. * **Automatic Retries:** Automatically retries requests that fail due to server errors (5xx) or transport-level errors. * **Configurable Retry Strategies:** * `FixedDelay`: Retries after a constant delay. @@ -17,7 +18,7 @@ * Base and maximum delay for backoff strategies. * Standard `http.Transport` settings (timeouts, keep-alives, connection pooling). * Overall request timeout (`http.Client.Timeout`). -* **Easy Integration:** Designed as a drop-in replacement for `http.Client`. +* **Easy Integration:** Designed as a complete drop-in replacement for `http.Client` - just change your client creation line and everything else works transparently. ## Installation @@ -27,9 +28,26 @@ go get github.com/p2p-b2b/httpretrier ## Usage -### Basic Usage with Default Transport +### Basic Transparent Usage (Recommended) -You can quickly create a client with a specific retry strategy and number of retries using `httpretrier.NewClient`. It uses `http.DefaultTransport` underneath. +The easiest way to add retry functionality is to replace your `http.Client` with `httpretrier.NewClient()`. It works transparently with all existing headers and authentication tokens. + +```go +// Before: client := &http.Client{} +client := httpretrier.NewClient(3, httpretrier.ExponentialBackoff(500*time.Millisecond, 10*time.Second), nil) + +// All your existing code works unchanged - auth tokens, custom headers, everything is preserved +req, _ := http.NewRequest("GET", "https://api.example.com/data", nil) +req.Header.Set("Authorization", "Bearer your-existing-token") +req.Header.Set("X-Custom-Header", "your-value") + +resp, err := client.Do(req) +// Automatically retries on 5xx errors while preserving all headers +``` + +### Basic Usage with Specific Configuration + +You can create a client with specific retry strategy and number of retries using `httpretrier.NewClient`. ```go package main @@ -88,6 +106,52 @@ func main() { // Client: Received response: Status=200 OK, Body='Success!' ``` +### Using Custom Transport + +You can provide your own `http.Transport` with specific settings for connection pooling, timeouts, TLS configuration, etc.: + +```go +package main + +import ( + "fmt" + "net/http" + "time" + + "github.com/p2p-b2b/httpretrier" +) + +func main() { + // Create a custom transport with specific settings + customTransport := &http.Transport{ + MaxIdleConns: 50, // Custom connection pool size + IdleConnTimeout: 30 * time.Second, // Custom idle timeout + DisableKeepAlives: false, // Enable keep-alives + MaxIdleConnsPerHost: 10, // Custom per-host connection limit + TLSHandshakeTimeout: 5 * time.Second, // Custom TLS timeout + } + + // Create retry client with your custom transport + client := httpretrier.NewClient( + 3, // Max retries + httpretrier.ExponentialBackoff(100*time.Millisecond, 1*time.Second), + customTransport, // Use your custom transport as the base + ) + + // Use the client normally - all your transport settings are preserved + resp, err := client.Get("https://api.example.com/data") + if err != nil { + fmt.Printf("Request failed: %v\n", err) + return + } + defer resp.Body.Close() + + // Your custom transport settings (connection pooling, timeouts) are used + // while still getting automatic retry functionality + fmt.Printf("Success with custom transport! Status: %d\n", resp.StatusCode) +} +``` + ### Advanced Configuration with ClientBuilder For more control over the client and transport settings, use the `ClientBuilder`. @@ -143,10 +207,171 @@ func main() { // Client (Builder): Received response: Status=200 OK, Body='Builder success!' ``` +## Authorization + +The library provides built-in support for common HTTP authentication patterns. Authorization is applied to all requests, including retries, and automatically handles 401 Unauthorized responses by attempting to refresh credentials when supported. + +### Bearer Token Authentication + +```go +// Simple Bearer token +client := httpretrier.NewClientBuilder(). + WithBearerToken("your-access-token"). + WithMaxRetries(3). + Build() + +// Bearer token with automatic refresh +client := httpretrier.NewClientBuilder(). + WithBearerTokenAndRefresh("initial-token", func() (string, error) { + // Your token refresh logic here + return refreshTokenFromAPI() + }). + WithMaxRetries(3). + Build() +``` + +### API Key Authentication + +```go +// API key in custom header +client := httpretrier.NewClientBuilder(). + WithAPIKey("your-api-key", "X-API-Key"). + WithMaxRetries(3). + Build() +``` + +### Basic Authentication + +```go +client := httpretrier.NewClientBuilder(). + WithBasicAuth("username", "password"). + WithMaxRetries(3). + Build() +``` + +### Custom Header Authentication + +```go +// Multiple custom headers +headers := map[string]string{ + "X-Client-ID": "your-client-id", + "X-Signature": "your-hmac-signature", +} + +client := httpretrier.NewClientBuilder(). + WithCustomHeaders(headers). + WithMaxRetries(3). + Build() +``` + +### Custom Authorizer + +For advanced authentication schemes, implement the `Authorizer` interface: + +```go +type MyCustomAuth struct { + // Your auth fields +} + +func (a *MyCustomAuth) Authorize(req *http.Request) error { + // Add your custom authorization logic + req.Header.Set("Authorization", "Custom "+a.Token) + return nil +} + +func (a *MyCustomAuth) RefreshIfNeeded() error { + // Optional: refresh logic for 401 responses + return nil +} + +// Use custom authorizer +client := httpretrier.NewClientBuilder(). + WithAuthorizer(&MyCustomAuth{}). + WithMaxRetries(3). + Build() +``` + +### Request-Level Authorization + +For scenarios where Bearer tokens are already present in requests (like proxy servers, middleware, or request forwarding): + +```go +// Use Bearer tokens from incoming requests +client := httpretrier.NewClientBuilder(). + WithRequestTokenAuth(false). // false = require token, true = allow empty + WithMaxRetries(3). + Build() + +// With automatic token refresh on 401 +client := httpretrier.NewClientBuilder(). + WithRequestTokenAuthAndRefresh(func(currentToken string) (string, error) { + // Refresh the token based on current token + return refreshToken(currentToken) + }, false). + WithMaxRetries(3). + Build() + +// Make request with existing Authorization header +req, _ := http.NewRequest("GET", "https://api.example.com", nil) +req.Header.Set("Authorization", "Bearer existing-token") +resp, err := client.Do(req) +``` + +### Passthrough Authorization + +Preserve existing authorization headers and optionally provide fallback authentication: + +```go +// Preserve existing auth, use fallback if none exists +client := httpretrier.NewClientBuilder(). + WithPassthroughAuth(NewBearerTokenAuth("fallback-token")). + Build() +``` + +### Conditional Authorization + +Apply different authentication strategies based on request context: + +```go +client := httpretrier.NewClientBuilder(). + WithConditionalAuth(func(req *http.Request) Authorizer { + service := req.Header.Get("X-Service") + switch service { + case "internal": + return NewBearerTokenAuth("internal-token") + case "external": + return NewAPIKeyAuth("external-key", "X-API-Key") + default: + return nil // No auth + } + }). + Build() +``` + +### Authorization with Retry Integration + +Authorization works seamlessly with retry logic: + +1. **Auth + Retry**: Authorization headers are added to each retry attempt +2. **401 Handling**: On 401 Unauthorized responses, the authorizer attempts to refresh credentials +3. **Automatic Retry**: After successful credential refresh, the request is automatically retried once +4. **Layered Approach**: Auth transport wraps retry transport, ensuring proper order of operations + ## Configuration Options (ClientBuilder) The `ClientBuilder` allows configuration of: +* **Authorization:** + * `WithBearerToken(token string)`: Add Bearer token authentication. + * `WithBearerTokenAndRefresh(token string, refreshFunc func() (string, error))`: Bearer token with refresh capability. + * `WithAPIKey(key, header string)`: Add API key authentication in specified header. + * `WithBasicAuth(username, password string)`: Add HTTP Basic authentication. + * `WithCustomHeaders(headers map[string]string)`: Add custom header authentication. + * `WithRequestTokenAuth(allowEmpty bool)`: Use Bearer tokens from incoming requests. + * `WithRequestTokenAuthAndRefresh(refreshFunc func(string) (string, error), allowEmpty bool)`: Request-level tokens with refresh. + * `WithPassthroughAuth(defaultAuth Authorizer)`: Preserve existing auth, use default if none. + * `WithConditionalAuth(condition func(*http.Request) Authorizer)`: Context-aware authorization. + * `WithAuthorizer(authorizer Authorizer)`: Use a custom authorizer implementation. * **Retry Logic:** * `WithMaxRetries(int)`: Maximum number of retry attempts. * `WithRetryStrategy(httpretrier.Strategy)`: Set the strategy (`FixedDelayStrategy`, `ExponentialBackoffStrategy`, `JitterBackoffStrategy`). diff --git a/example_httpretrier_test.go b/example_httpretrier_test.go index a4d840a..6c225ef 100644 --- a/example_httpretrier_test.go +++ b/example_httpretrier_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "sync/atomic" "time" @@ -48,7 +49,7 @@ func Example() { // Note: Duration will vary slightly, but should reflect increasing delays. fmt.Printf("Client: Total time approx > %dms (due to backoff)\n", (5 + 10 + 20)) // 5ms + 10ms + 20ms delays - // Example Output (delays are approximate): + // Output: // Client: Making request with exponential backoff... // Server: Request 1 -> 500 Internal Server Error // Server: Request 2 -> 500 Internal Server Error @@ -57,3 +58,168 @@ func Example() { // Client: Received response: Status=200 OK, Body='Success after backoff' // Client: Total time approx > 35ms (due to backoff) } + +// ExampleNewClient_withExistingAuth demonstrates how the default client +// transparently preserves existing authentication headers in requests. +func ExampleNewClient_withExistingAuth() { + var requestCount int32 + + // Create a server that requires authentication + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := atomic.AddInt32(&requestCount, 1) + auth := r.Header.Get("Authorization") + + if auth == "" { + fmt.Printf("Server: Request %d -> 401 Unauthorized (no auth)\n", count) + w.WriteHeader(http.StatusUnauthorized) + return + } + + fmt.Printf("Server: Request %d with %s -> ", count, auth) + if count <= 2 { + fmt.Println("500 Internal Server Error") + w.WriteHeader(http.StatusInternalServerError) + } else { + fmt.Println("200 OK") + w.WriteHeader(http.StatusOK) + w.Write([]byte("Authenticated and retried successfully")) + } + })) + defer server.Close() + + // Create default client - works transparently with any existing auth + client := httpretrier.NewClient(3, httpretrier.ExponentialBackoff(5*time.Millisecond, 50*time.Millisecond), nil) + + // Create request with existing auth token (from your app's auth system) + req, _ := http.NewRequest("GET", server.URL, nil) + req.Header.Set("Authorization", "Bearer my-token-123") + + fmt.Println("Client: Making authenticated request...") + resp, err := client.Do(req) + if err != nil { + fmt.Printf("Client: Request failed: %v\n", err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Client: Success! Status=%s, Body='%s'\n", resp.Status, string(body)) + fmt.Printf("Client: Auth header preserved through %d retries\n", atomic.LoadInt32(&requestCount)) + + // Output: + // Client: Making authenticated request... + // Server: Request 1 with Bearer my-token-123 -> 500 Internal Server Error + // Server: Request 2 with Bearer my-token-123 -> 500 Internal Server Error + // Server: Request 3 with Bearer my-token-123 -> 200 OK + // Client: Success! Status=200 OK, Body='Authenticated and retried successfully' + // Client: Auth header preserved through 3 retries +} + +// ExampleNewClientBuilder_transparent demonstrates using the ClientBuilder +// for advanced configuration while maintaining transparent behavior. +func ExampleNewClientBuilder_transparent() { + // Create a simple test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Echo back any custom headers that were sent + customValue := r.Header.Get("X-Custom-Header") + if customValue != "" { + fmt.Printf("Server: Received custom header: %s\n", customValue) + } + w.WriteHeader(http.StatusOK) + w.Write([]byte("Custom headers preserved!")) + })) + defer server.Close() + + // Build client with custom settings - still works transparently + client := httpretrier.NewClientBuilder(). + WithMaxRetries(5). + WithRetryStrategy(httpretrier.JitterBackoffStrategy). + WithTimeout(10 * time.Second). + Build() + + // Create request with custom headers + req, _ := http.NewRequest("GET", server.URL, nil) + req.Header.Set("X-Custom-Header", "my-custom-value") + req.Header.Set("Authorization", "Bearer token-from-somewhere") + + fmt.Println("Client: Making request with custom headers...") + resp, err := client.Do(req) + if err != nil { + fmt.Printf("Client: Request failed: %v\n", err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Client: Response: %s\n", string(body)) + + // Output: + // Client: Making request with custom headers... + // Server: Received custom header: my-custom-value + // Client: Response: Custom headers preserved! +} + +// ExampleNewClient_withCustomTransport demonstrates using a custom base transport +// with specific transport settings while maintaining transparent retry behavior. +func ExampleNewClient_withCustomTransport() { + var requestCount int32 + + // Create a test server that fails initially to show retry behavior with custom transport + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := atomic.AddInt32(&requestCount, 1) + fmt.Printf("Server: Request %d from custom transport\n", count) + + if count <= 1 { + w.WriteHeader(http.StatusInternalServerError) + } else { + w.WriteHeader(http.StatusOK) + w.Write([]byte("Custom transport with retries works!")) + } + })) + defer server.Close() + + // Create a custom transport with specific settings + customTransport := &http.Transport{ + MaxIdleConns: 50, // Custom connection pool size + IdleConnTimeout: 30 * time.Second, // Custom idle timeout + DisableKeepAlives: false, // Enable keep-alives + MaxIdleConnsPerHost: 10, // Custom per-host connection limit + TLSHandshakeTimeout: 5 * time.Second, // Custom TLS timeout + } + + // Create retry client with custom transport + client := httpretrier.NewClient( + 3, // Max retries + httpretrier.ExponentialBackoff(5*time.Millisecond, 50*time.Millisecond), + customTransport, // Use our custom transport as the base + ) + + fmt.Println("Client: Making request with custom transport...") + resp, err := client.Get(server.URL) + if err != nil { + fmt.Printf("Client: Request failed: %v\n", err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Client: Response: %s\n", string(body)) + fmt.Printf("Client: Custom transport config preserved (MaxIdleConns: %d)\n", + customTransport.MaxIdleConns) + + // Output: + // Client: Making request with custom transport... + // Server: Request 1 from custom transport + // Server: Request 2 from custom transport + // Client: Response: Custom transport with retries works! + // Client: Custom transport config preserved (MaxIdleConns: 50) +} + +// Helper function to parse URL (avoiding error handling in example) +func mustParseURL(rawURL string) *url.URL { + u, err := url.Parse(rawURL) + if err != nil { + panic(err) + } + return u +} diff --git a/go.mod b/go.mod index 9b9926c..0bec9c2 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,3 @@ module github.com/p2p-b2b/httpretrier -go 1.24.2 - -require github.com/stretchr/testify v1.10.0 - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) +go 1.25.1 diff --git a/go.sum b/go.sum index fe99d71..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +0,0 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/http_client.go b/http_client.go index b550fec..8c9fff7 100644 --- a/http_client.go +++ b/http_client.go @@ -91,7 +91,7 @@ func (s Strategy) IsValid() bool { } // Client is a custom HTTP client with configurable settings -// and retry strategies +// and retry strategies. Works transparently with existing request headers. type Client struct { maxIdleConns int idleConnTimeout time.Duration @@ -143,52 +143,27 @@ func (b *ClientBuilder) WithMaxIdleConns(maxIdleConns int) *ClientBuilder { // WithIdleConnTimeout sets the idle connection timeout // and returns the ClientBuilder for method chaining -// Valid range: 1 second to 120 seconds -// If the value is invalid, a warning is logged and the default value is used -// This setting is useful for controlling the time the client waits -// before closing idle connections -// The idle connection timeout is the time the client waits -// before closing an idle connection -// The value must be between ValidMinIdleConnTimeout and ValidMaxIdleConnTimeout -// If the value is invalid, a warning is logged and the default value is used -// This setting is useful for controlling the time the client waits func (b *ClientBuilder) WithIdleConnTimeout(idleConnTimeout time.Duration) *ClientBuilder { - // Just set the value, Build will validate/default b.client.idleConnTimeout = idleConnTimeout return b } // WithTLSHandshakeTimeout sets the TLS handshake timeout // and returns the ClientBuilder for method chaining -// It is important to note that the TLS handshake timeout -// is not the same as the overall timeout for the HTTP request -// The TLS handshake timeout is the time allowed for the TLS handshake -// to complete before the connection is closed -// The value must be between ValidMinTLSHandshakeTimeout and ValidMaxTLSHandshakeTimeout -// If the value is invalid, a warning is logged and the default value is used -// This setting is useful for controlling the time the client waits func (b *ClientBuilder) WithTLSHandshakeTimeout(tlsHandshakeTimeout time.Duration) *ClientBuilder { - // Just set the value, Build will validate/default b.client.tlsHandshakeTimeout = tlsHandshakeTimeout return b } // WithExpectContinueTimeout sets the expect continue timeout // and returns the ClientBuilder for method chaining -// This timeout is used for HTTP/1.1 requests with Expect: 100-continue -// The value must be between ValidMinExpectContinueTimeout and ValidMaxExpectContinueTimeout -// If the value is invalid, a warning is logged and the default value is used -// This setting is useful for controlling the time the client waits func (b *ClientBuilder) WithExpectContinueTimeout(expectContinueTimeout time.Duration) *ClientBuilder { - // Just set the value, Build will validate/default b.client.expectContinueTimeout = expectContinueTimeout return b } -// WithDisableKeepAlives sets the disable keep-alives setting +// WithDisableKeepAlives sets whether to disable keep-alives // and returns the ClientBuilder for method chaining -// This setting controls whether the client should keep connections alive -// after a request is completed func (b *ClientBuilder) WithDisableKeepAlives(disableKeepAlives bool) *ClientBuilder { b.client.disableKeepAlives = disableKeepAlives return b @@ -196,88 +171,62 @@ func (b *ClientBuilder) WithDisableKeepAlives(disableKeepAlives bool) *ClientBui // WithMaxIdleConnsPerHost sets the maximum number of idle connections per host // and returns the ClientBuilder for method chaining -// This is a performance optimization for HTTP/1.1 -// The value must be between ValidMinIdleConnsPerHost and ValidMaxIdleConnsPerHost -// If the value is invalid, a warning is logged and the default value is used func (b *ClientBuilder) WithMaxIdleConnsPerHost(maxIdleConnsPerHost int) *ClientBuilder { - // Just set the value, Build will validate/default b.client.maxIdleConnsPerHost = maxIdleConnsPerHost return b } // WithTimeout sets the timeout for HTTP requests // and returns the ClientBuilder for method chaining -// The timeout must be between ValidMinTimeout and ValidMaxTimeout -// If the timeout is invalid, a warning is logged and the default value is used func (b *ClientBuilder) WithTimeout(timeout time.Duration) *ClientBuilder { - // Just set the value, Build will validate/default b.client.timeout = timeout return b } // WithMaxRetries sets the maximum number of retry attempts // and returns the ClientBuilder for method chaining -// The maximum number of retries must be between ValidMinRetries and ValidMaxRetries -// If the maximum number of retries is invalid, a warning is logged and the default value is used -// This setting is useful for controlling the number of retry attempts -// The maximum number of retries is the maximum number of times -// the client will retry a failed request func (b *ClientBuilder) WithMaxRetries(maxRetries int) *ClientBuilder { - // Just set the value, Build will validate/default b.client.maxRetries = maxRetries return b } -// WithRetryBaseDelay sets the base delay for retry strategies like ExponentialBackoff and JitterBackoff. -// For FixedDelay, this sets the fixed delay duration. +// WithRetryBaseDelay sets the base delay for retry strategies +// and returns the ClientBuilder for method chaining func (b *ClientBuilder) WithRetryBaseDelay(baseDelay time.Duration) *ClientBuilder { - // Just set the value, Build will validate/default b.client.retryBaseDelay = baseDelay return b } -// WithRetryMaxDelay sets the maximum delay for retry strategies like ExponentialBackoff and JitterBackoff. -// This value is ignored by FixedDelay. +// WithRetryMaxDelay sets the maximum delay for retry strategies +// and returns the ClientBuilder for method chaining func (b *ClientBuilder) WithRetryMaxDelay(maxDelay time.Duration) *ClientBuilder { - // Just set the value, Build will validate/default b.client.retryMaxDelay = maxDelay return b } -// WithRetryStrategy sets the retry strategy for the client +// WithRetryStrategy sets the retry strategy type // and returns the ClientBuilder for method chaining -// The retry strategy determines how the client will handle -// retrying failed requests -// The retry strategy can be one of the following: -// "fixed", "jitter", or "exponential" -// If the retry strategy is invalid, a warning is logged and the default value is used -// This setting is useful for controlling the retry behavior -// The retry strategy is the strategy used to determine the delay -// between retry attempts -func (b *ClientBuilder) WithRetryStrategy(retryStrategy Strategy) *ClientBuilder { - // Validate the strategy type itself - // Just set the type, Build will validate/default - b.client.retryStrategyType = retryStrategy +func (b *ClientBuilder) WithRetryStrategy(strategy Strategy) *ClientBuilder { + // Set the value as-is, validation happens during Build() + b.client.retryStrategyType = strategy return b } -// WithRetryStrategyAsString sets the retry strategy for the client -// using a string representation of the strategy type +// WithRetryStrategyAsString sets the retry strategy type from a string // and returns the ClientBuilder for method chaining -func (b *ClientBuilder) WithRetryStrategyAsString(retryStrategy string) *ClientBuilder { - strategy := Strategy(retryStrategy) - if !strategy.IsValid() { - slog.Warn("Invalid retry strategy type, using default (Exponential)", "invalidValue", retryStrategy, "defaultValue", ExponentialBackoffStrategy) - strategy = ExponentialBackoffStrategy +func (b *ClientBuilder) WithRetryStrategyAsString(strategy string) *ClientBuilder { + s := Strategy(strategy) + if !s.IsValid() { + slog.Warn("Invalid retry strategy type, using default (Exponential)", "invalidValue", s, "defaultValue", ExponentialBackoffStrategy) + s = ExponentialBackoffStrategy } - - b.client.retryStrategyType = strategy - + b.client.retryStrategyType = s return b } // Build creates and returns a new HTTP client with the specified settings -// and retry strategy +// and retry strategy. The client works transparently, preserving any existing +// headers in requests without requiring explicit configuration. func (b *ClientBuilder) Build() *http.Client { // validate the settings and set defaults if necessary @@ -316,14 +265,13 @@ func (b *ClientBuilder) Build() *http.Client { b.client.maxRetries = DefaultMaxRetries } - // Validate delays *before* creating the strategy function if b.client.retryBaseDelay < ValidMinBaseDelay || b.client.retryBaseDelay > ValidMaxBaseDelay { - slog.Warn("Invalid base delay, using default value", "invalidValue", b.client.retryBaseDelay, "defaultValue", DefaultBaseDelay) + slog.Warn("Invalid retry base delay, using default value", "invalidValue", b.client.retryBaseDelay, "defaultValue", DefaultBaseDelay) b.client.retryBaseDelay = DefaultBaseDelay } if b.client.retryMaxDelay < ValidMinMaxDelay || b.client.retryMaxDelay > ValidMaxMaxDelay { - slog.Warn("Invalid max delay, using default value", "invalidValue", b.client.retryMaxDelay, "defaultValue", DefaultMaxDelay) + slog.Warn("Invalid retry max delay, using default value", "invalidValue", b.client.retryMaxDelay, "defaultValue", DefaultMaxDelay) b.client.retryMaxDelay = DefaultMaxDelay } @@ -362,13 +310,17 @@ func (b *ClientBuilder) Build() *http.Client { MaxIdleConnsPerHost: b.client.maxIdleConnsPerHost, } + // Create retry transport - this is the only layer needed for transparent operation + // It automatically preserves all existing headers without any explicit auth configuration + finalTransport := &retryTransport{ + Transport: transport, + MaxRetries: b.client.maxRetries, + RetryStrategy: finalRetryStrategy, + } + // Create the HTTP client with the specified settings return &http.Client{ - Timeout: b.client.timeout, - Transport: &retryTransport{ - Transport: transport, - MaxRetries: b.client.maxRetries, - RetryStrategy: finalRetryStrategy, // Use the function created in Build - }, + Timeout: b.client.timeout, + Transport: finalTransport, } } diff --git a/http_client_test.go b/http_client_test.go index 97a1c14..c4f3125 100644 --- a/http_client_test.go +++ b/http_client_test.go @@ -2,12 +2,33 @@ package httpretrier import ( "net/http" + "reflect" "testing" "time" - - "github.com/stretchr/testify/assert" ) +// Helper functions to replace testify assertions +func assertEqual(t *testing.T, expected, actual interface{}) { + t.Helper() + if !reflect.DeepEqual(expected, actual) { + t.Errorf("Expected %v, got %v", expected, actual) + } +} + +func assertTrue(t *testing.T, condition bool) { + t.Helper() + if !condition { + t.Error("Expected condition to be true") + } +} + +func assertNotNil(t *testing.T, value interface{}) { + t.Helper() + if value == nil { + t.Error("Expected value to be non-nil") + } +} + func TestClientBuilder_WithMethods(t *testing.T) { builder := NewClientBuilder() @@ -25,18 +46,19 @@ func TestClientBuilder_WithMethods(t *testing.T) { WithRetryStrategy(FixedDelayStrategy) client := builder.client - assert.Equal(t, 50, client.maxIdleConns) - assert.Equal(t, 60*time.Second, client.idleConnTimeout) - assert.Equal(t, 5*time.Second, client.tlsHandshakeTimeout) - assert.Equal(t, 2*time.Second, client.expectContinueTimeout) - assert.True(t, client.disableKeepAlives) - assert.Equal(t, 50, client.maxIdleConnsPerHost) - assert.Equal(t, 10*time.Second, client.timeout) - assert.Equal(t, 5, client.maxRetries) - assert.Equal(t, 100*time.Millisecond, client.retryBaseDelay) // Check the value *set* by WithRetryBaseDelay - assert.Equal(t, 5*time.Second, client.retryMaxDelay) - // Check the strategy *type* was set - assert.Equal(t, FixedDelayStrategy, client.retryStrategyType) // Check the type *set* by WithRetryStrategy + // Assert that the With... methods *set* the values on the internal client struct + assertEqual(t, 50, client.maxIdleConns) + assertEqual(t, 60*time.Second, client.idleConnTimeout) + assertEqual(t, 5*time.Second, client.tlsHandshakeTimeout) + assertEqual(t, 2*time.Second, client.expectContinueTimeout) + assertTrue(t, client.disableKeepAlives) + assertEqual(t, 50, client.maxIdleConnsPerHost) + assertEqual(t, 10*time.Second, client.timeout) + assertEqual(t, 5, client.maxRetries) + assertEqual(t, 100*time.Millisecond, client.retryBaseDelay) // Check the value *set* by WithRetryBaseDelay + assertEqual(t, 5*time.Second, client.retryMaxDelay) + // Check that the strategy type was set correctly + assertEqual(t, FixedDelayStrategy, client.retryStrategyType) // Check the type *set* by WithRetryStrategy // Test invalid settings (should use defaults or adjusted values) builder = NewClientBuilder() // Reset builder @@ -53,17 +75,17 @@ func TestClientBuilder_WithMethods(t *testing.T) { client = builder.client // Assert that the *invalid* values were set by the With... methods (before Build validation) - assert.Equal(t, 0, client.maxIdleConns) - assert.Equal(t, 0*time.Second, client.idleConnTimeout) - assert.Equal(t, 0*time.Second, client.tlsHandshakeTimeout) - assert.Equal(t, 0*time.Second, client.expectContinueTimeout) - assert.Equal(t, 0, client.maxIdleConnsPerHost) - assert.Equal(t, 0*time.Second, client.timeout) - assert.Equal(t, 0, client.maxRetries) - assert.Equal(t, 1*time.Millisecond, client.retryBaseDelay) - assert.Equal(t, 50*time.Millisecond, client.retryMaxDelay) + assertEqual(t, 0, client.maxIdleConns) + assertEqual(t, 0*time.Second, client.idleConnTimeout) + assertEqual(t, 0*time.Second, client.tlsHandshakeTimeout) + assertEqual(t, 0*time.Second, client.expectContinueTimeout) + assertEqual(t, 0, client.maxIdleConnsPerHost) + assertEqual(t, 0*time.Second, client.timeout) + assertEqual(t, 0, client.maxRetries) + assertEqual(t, 1*time.Millisecond, client.retryBaseDelay) + assertEqual(t, 50*time.Millisecond, client.retryMaxDelay) // Check that the invalid strategy type was set - assert.Equal(t, Strategy("invalid"), client.retryStrategyType) + assertEqual(t, Strategy("invalid"), client.retryStrategyType) } func TestClientBuilder_Build(t *testing.T) { @@ -78,91 +100,52 @@ func TestClientBuilder_Build(t *testing.T) { WithExpectContinueTimeout(3 * time.Second). WithDisableKeepAlives(true). WithMaxIdleConnsPerHost(55). - WithTimeout(11 * time.Second). + WithTimeout(15 * time.Second). WithMaxRetries(maxRetries). - WithRetryBaseDelay(baseDelay). + WithRetryBaseDelay(baseDelay). // Invalid, should be corrected to default WithRetryMaxDelay(maxDelay). WithRetryStrategy(JitterBackoffStrategy) httpClient := builder.Build() - assert.NotNil(t, httpClient) - assert.Equal(t, 11*time.Second, httpClient.Timeout) - assert.NotNil(t, httpClient.Transport) - - // Check if transport is retryTransport - rt, ok := httpClient.Transport.(*retryTransport) - assert.True(t, ok, "Transport should be of type *retryTransport") - assert.NotNil(t, rt) - - // Check retryTransport settings - assert.Equal(t, maxRetries, rt.MaxRetries) - assert.NotNil(t, rt.RetryStrategy) - // Verify the strategy function produces a delay within the expected range for Jitter - // (This is an indirect way to check if the correct strategy function was set) - attempt := 1 - // IMPORTANT: Calculate expected delay using the *validated* baseDelay from the builder, - // as the initial baseDelay (200ms) is invalid (< 300ms) and will be defaulted in Build. - validatedBaseDelay := builder.client.retryBaseDelay // Get the delay after Build's validation - if validatedBaseDelay < ValidMinBaseDelay || validatedBaseDelay > ValidMaxBaseDelay { - validatedBaseDelay = DefaultBaseDelay // Manually apply the same defaulting logic as Build - } - validatedMaxDelay := builder.client.retryMaxDelay // Get the validated max delay - if validatedMaxDelay < ValidMinMaxDelay || validatedMaxDelay > ValidMaxMaxDelay { - validatedMaxDelay = DefaultMaxDelay + // Verify the HTTP client was built + assertNotNil(t, httpClient) + assertNotNil(t, httpClient.Transport) + + // Verify timeout + assertEqual(t, 15*time.Second, httpClient.Timeout) + + // Test the transport is a retry transport + if retryTrans, ok := httpClient.Transport.(*retryTransport); ok { + assertEqual(t, maxRetries, retryTrans.MaxRetries) + assertNotNil(t, retryTrans.RetryStrategy) + + // Test that the underlying transport has the right settings + if baseTrans, ok := retryTrans.Transport.(*http.Transport); ok { + assertEqual(t, 55, baseTrans.MaxIdleConns) + assertEqual(t, 65*time.Second, baseTrans.IdleConnTimeout) + assertEqual(t, 6*time.Second, baseTrans.TLSHandshakeTimeout) + assertEqual(t, 3*time.Second, baseTrans.ExpectContinueTimeout) + assertTrue(t, baseTrans.DisableKeepAlives) + assertEqual(t, 55, baseTrans.MaxIdleConnsPerHost) + } else { + t.Error("Expected underlying transport to be *http.Transport") + } + } else { + t.Error("Expected transport to be *retryTransport") } - - // Calculate the expected exponential backoff delay for this attempt using validated delays - expectedExpDelay := ExponentialBackoff(validatedBaseDelay, validatedMaxDelay)(attempt) - // Now get the actual delay which includes jitter - actualDelay := rt.RetryStrategy(attempt) - - // Jitter delay should be >= the exponential delay for that attempt - assert.GreaterOrEqual(t, actualDelay, expectedExpDelay, "Jitter delay for attempt %d should be >= exponential backoff delay (%v)", attempt, expectedExpDelay) - // Max jitter delay is exponential delay + (exponential delay / 2) - maxExpectedJitterDelay := expectedExpDelay + (expectedExpDelay / 2) - assert.Less(t, actualDelay, maxExpectedJitterDelay, "Jitter delay for attempt %d (%v) should be < exponential backoff delay + half (%v)", attempt, actualDelay, maxExpectedJitterDelay) - - // Check underlying http.Transport settings - stdTransport, ok := rt.Transport.(*http.Transport) - assert.True(t, ok, "Inner transport should be of type *http.Transport") - assert.NotNil(t, stdTransport) - - assert.Equal(t, 55, stdTransport.MaxIdleConns) - assert.Equal(t, 65*time.Second, stdTransport.IdleConnTimeout) - assert.Equal(t, 6*time.Second, stdTransport.TLSHandshakeTimeout) - assert.Equal(t, 3*time.Second, stdTransport.ExpectContinueTimeout) - assert.True(t, stdTransport.DisableKeepAlives) - assert.Equal(t, 55, stdTransport.MaxIdleConnsPerHost) - - // Test building with default strategy (Exponential) - builder = NewClientBuilder() - httpClient = builder.Build() - rt, _ = httpClient.Transport.(*retryTransport) - delay := rt.RetryStrategy(1) // Attempt 1 - expectedDelay := DefaultBaseDelay * 2 // Exponential backoff doubles for attempt 1 - assert.Equal(t, expectedDelay, delay, "Default strategy (Exponential) delay check failed") - - // Test building with FixedDelay strategy - builder = NewClientBuilder().WithRetryBaseDelay(1 * time.Second).WithRetryStrategy(FixedDelayStrategy) - httpClient = builder.Build() - rt, _ = httpClient.Transport.(*retryTransport) - delay = rt.RetryStrategy(1) // Attempt 1 - assert.Equal(t, 1*time.Second, delay, "FixedDelay strategy delay check failed") - delay = rt.RetryStrategy(5) // Attempt 5 - assert.Equal(t, 1*time.Second, delay, "FixedDelay strategy delay check failed") } func TestStrategyString(t *testing.T) { - assert.Equal(t, "fixed", FixedDelayStrategy.String()) - assert.Equal(t, "jitter", JitterBackoffStrategy.String()) - assert.Equal(t, "exponential", ExponentialBackoffStrategy.String()) - assert.Equal(t, "unknown", Strategy("unknown").String()) + assertEqual(t, "fixed", FixedDelayStrategy.String()) + assertEqual(t, "jitter", JitterBackoffStrategy.String()) + assertEqual(t, "exponential", ExponentialBackoffStrategy.String()) + assertEqual(t, "unknown", Strategy("unknown").String()) } func TestClientError(t *testing.T) { err := &ClientError{Message: "test error"} - assert.Equal(t, "test error", err.Error()) + assertEqual(t, "test error", err.Error()) } func TestClientBuilder_WithRetryStrategyAsString(t *testing.T) { @@ -210,7 +193,7 @@ func TestClientBuilder_WithRetryStrategyAsString(t *testing.T) { builder.WithRetryStrategyAsString(tt.inputStrategy) // Assert that the correct strategy *type* was set on the internal client struct - assert.Equal(t, tt.expectedType, builder.client.retryStrategyType) + assertEqual(t, tt.expectedType, builder.client.retryStrategyType) // Note: We expect a warning log for invalid strategies, but testing logs // usually requires more setup (e.g., capturing log output). diff --git a/httpretrier.go b/httpretrier.go index 7326216..7526518 100644 --- a/httpretrier.go +++ b/httpretrier.go @@ -121,7 +121,7 @@ func (r *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) { // Check if we should retry if attempt < r.MaxRetries { delay := retryStrategy(attempt) - fmt.Printf("Attempt %d failed. Retrying after %v...\n", attempt+1, delay) // Consider using a logger + // Silent retry - no debug output for clean, transparent operation time.Sleep(delay) } else { // Max retries reached, return the last error or a generic failure error