From 3496fca6b3b1a70c8ff3f555081d8d2bb9ceed13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Mon, 19 Jan 2026 11:58:14 +0100 Subject: [PATCH] feat: support proxy in transport layer --- README.md | 170 ++++++++++++++++++++++++++++++++++++ docs.go | 63 +++++++++++++ example_proxy_test.go | 156 +++++++++++++++++++++++++++++++++ http_client.go | 24 +++++ http_client_test.go | 97 ++++++++++++++++++++ http_generic_client.go | 14 +++ http_generic_client_test.go | 120 +++++++++++++++++++++++++ http_retrier.go | 35 ++++++++ http_retrier_test.go | 137 +++++++++++++++++++++++++++++ 9 files changed, 816 insertions(+) create mode 100644 example_proxy_test.go diff --git a/README.md b/README.md index 5a8ea20..5a67c85 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A comprehensive Go package for building and executing HTTP requests with advance - 🎯 **Type-Safe Generic Client** - Go generics for type-safe HTTP responses - ✅ **Input Validation** - Comprehensive validation with error accumulation - 🔐 **Authentication Support** - Built-in Basic and Bearer token authentication +- 🌐 **Proxy Support** - HTTP/HTTPS proxy configuration with authentication (supports corporate proxies, authenticated proxies, and custom ports) - 📝 **Optional Logging** - slog integration for observability (disabled by default) - 📦 **Zero External Dependencies** - Only Go standard library, no third-party packages @@ -31,6 +32,7 @@ A comprehensive Go package for building and executing HTTP requests with advance - [Generic HTTP Client](#generic-http-client) - [Retry Logic](#retry-logic) - [Client Builder](#client-builder) + - [Proxy Configuration](#proxy-configuration) - [Logging](#logging) - [Examples](#examples) - [API Reference](#api-reference) @@ -446,6 +448,174 @@ client := httpx.NewClientBuilder(). The builder validates all settings and uses defaults for out-of-range values. +### Proxy Configuration + +The httpx package provides comprehensive HTTP/HTTPS proxy support across all client types. Configure proxies to route your requests through corporate firewalls, load balancers, or testing proxies. + +#### Key Features + +- ✅ HTTP and HTTPS proxy support +- 🔐 Proxy authentication (username/password) +- 🔄 Works with retry logic +- 🎯 Compatible with all client types +- 🌐 Full URL or host:port formats +- 📝 Graceful fallback on invalid URLs + +#### Basic Usage + +##### With ClientBuilder + +```go +// HTTP proxy +client := httpx.NewClientBuilder(). + WithProxy("http://proxy.example.com:8080"). + WithTimeout(10 * time.Second). + Build() + +// HTTPS proxy +client := httpx.NewClientBuilder(). + WithProxy("https://secure-proxy.example.com:3128"). + Build() +``` + +##### With GenericClient + +```go +type User struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +client := httpx.NewGenericClient[User]( + httpx.WithProxy[User]("http://proxy.example.com:8080"), + httpx.WithTimeout[User](10*time.Second), + httpx.WithMaxRetries[User](3), +) + +response, err := client.Get("https://api.example.com/users/1") +``` + +##### With Retry Client + +```go +client := httpx.NewHTTPRetryClient( + httpx.WithProxyRetry("http://proxy.example.com:8080"), + httpx.WithMaxRetriesRetry(5), + httpx.WithRetryStrategyRetry( + httpx.ExponentialBackoff(500*time.Millisecond, 30*time.Second), + ), +) +``` + +#### Proxy Authentication + +Include credentials directly in the proxy URL: + +```go +client := httpx.NewClientBuilder(). + WithProxy("http://username:password@proxy.example.com:8080"). + Build() +``` + +**Security Note:** For production, consider using environment variables or secret management: + +```go +proxyURL := fmt.Sprintf("http://%s:%s@%s:%s", + os.Getenv("PROXY_USER"), + os.Getenv("PROXY_PASS"), + os.Getenv("PROXY_HOST"), + os.Getenv("PROXY_PORT"), +) + +client := httpx.NewClientBuilder(). + WithProxy(proxyURL). + Build() +``` + +#### Common Proxy Ports + +- **HTTP Proxy**: 8080, 3128, 8888 +- **HTTPS Proxy**: 3128, 8443 +- **Squid**: 3128 (most common) +- **Corporate Proxies**: 8080, 80 + +#### Disable Proxy + +Override environment proxy settings by passing an empty string: + +```go +// Disable proxy (ignore HTTP_PROXY environment variable) +client := httpx.NewClientBuilder(). + WithProxy(""). + Build() +``` + +#### Complete Example + +```go +package main + +import ( + "fmt" + "log" + "time" + + "github.com/slashdevops/httpx" +) + +type APIResponse struct { + Message string `json:"message"` + Status string `json:"status"` +} + +func main() { + // Configure client with proxy and full options + client := httpx.NewGenericClient[APIResponse]( + httpx.WithProxy[APIResponse]("http://proxy.example.com:8080"), + httpx.WithTimeout[APIResponse](15*time.Second), + httpx.WithMaxRetries[APIResponse](5), + httpx.WithRetryStrategy[APIResponse](httpx.JitterBackoffStrategy), + httpx.WithRetryBaseDelay[APIResponse](500*time.Millisecond), + httpx.WithRetryMaxDelay[APIResponse](30*time.Second), + ) + + // Build request with authentication + req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithPath("/data"). + WithBearerAuth("your-token-here"). + WithHeader("Accept", "application/json"). + Build() + + if err != nil { + log.Fatal(err) + } + + // Execute through proxy + response, err := client.Do(req) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Response: %s\n", response.Data.Message) +} +``` + +#### Error Handling + +The library gracefully handles proxy configuration errors: + +```go +client := httpx.NewClientBuilder(). + WithProxy("://invalid-url"). // Invalid URL + WithLogger(logger). // Optional: log warnings + Build() + +// Client builds successfully, but proxy is not configured +// Warning logged if logger is provided +``` + ### Logging The httpx package supports optional logging using Go's standard `log/slog` package. **Logging is disabled by default** to maintain clean, silent HTTP operations. Enable it when you need observability into retries, errors, and other HTTP client operations. diff --git a/docs.go b/docs.go index 3501858..75adf94 100644 --- a/docs.go +++ b/docs.go @@ -147,6 +147,7 @@ // - WithExpectContinueTimeout: Set expect continue timeout // - WithMaxIdleConnsPerHost: Set maximum idle connections per host // - WithDisableKeepAlive: Disable HTTP keep-alive +// - WithProxy: Configure HTTP/HTTPS proxy server // - WithHTTPClient: Use a pre-configured HTTP client (takes precedence) // // Integration with RequestBuilder: @@ -257,6 +258,9 @@ // WithRetryBaseDelay(500 * time.Millisecond). // WithRetryMaxDelay(10 * time.Second). // +// // Proxy configuration +// WithProxy("http://proxy.example.com:8080"). +// // Build() // // Combine with GenericClient: @@ -360,6 +364,65 @@ // // Handle specific status codes // } // +// # Proxy Configuration +// +// The package provides comprehensive proxy support for all HTTP clients. +// Proxy configuration works transparently across all client types and +// supports both HTTP and HTTPS proxies with optional authentication. +// +// Basic proxy configuration with ClientBuilder: +// +// client := httpx.NewClientBuilder(). +// WithProxy("http://proxy.example.com:8080"). +// Build() +// +// HTTPS proxy: +// +// client := httpx.NewClientBuilder(). +// WithProxy("https://secure-proxy.example.com:3128"). +// Build() +// +// Proxy with authentication: +// +// client := httpx.NewClientBuilder(). +// WithProxy("http://username:password@proxy.example.com:8080"). +// Build() +// +// Proxy with GenericClient: +// +// client := httpx.NewGenericClient[User]( +// httpx.WithProxy[User]("http://proxy.example.com:8080"), +// httpx.WithTimeout[User](10*time.Second), +// httpx.WithMaxRetries[User](3), +// ) +// +// Proxy with retry client: +// +// client := httpx.NewHTTPRetryClient( +// httpx.WithProxyRetry("http://proxy.example.com:8080"), +// httpx.WithMaxRetriesRetry(5), +// ) +// +// Disable proxy (override environment variables): +// +// client := httpx.NewClientBuilder(). +// WithProxy(""). // Empty string disables proxy +// Build() +// +// Common proxy ports: +// - HTTP proxy: 8080, 3128, 8888 +// - HTTPS proxy: 3128, 8443 +// - SOCKS proxy: 1080 (not directly supported, use custom transport) +// +// The proxy configuration: +// - Works transparently with all request types +// - Preserves all headers and authentication +// - Compatible with retry logic +// - Supports connection pooling +// - Respects timeout settings +// - Validates proxy URL format +// - Falls back gracefully on invalid URLs +// // # Thread Safety // // All utilities in this package are safe for concurrent use across multiple goroutines: diff --git a/example_proxy_test.go b/example_proxy_test.go new file mode 100644 index 0000000..af188fe --- /dev/null +++ b/example_proxy_test.go @@ -0,0 +1,156 @@ +package httpx_test + +import ( + "fmt" + "time" + + "github.com/slashdevops/httpx" +) + +// ExampleClientBuilder_WithProxy demonstrates how to configure an HTTP client with a proxy. +func ExampleClientBuilder_WithProxy() { + // Create an HTTP client with proxy configuration + client := httpx.NewClientBuilder(). + WithProxy("http://proxy.example.com:8080"). + WithTimeout(10 * time.Second). + WithMaxRetries(3). + WithRetryStrategy(httpx.ExponentialBackoffStrategy). + Build() + + // Client is ready to use with proxy + fmt.Printf("Client configured with proxy\n") + _ = client + + // Output: Client configured with proxy +} + +// ExampleClientBuilder_WithProxy_https demonstrates using an HTTPS proxy. +func ExampleClientBuilder_WithProxy_https() { + // Create an HTTP client with HTTPS proxy + client := httpx.NewClientBuilder(). + WithProxy("https://secure-proxy.example.com:3128"). + WithTimeout(15 * time.Second). + Build() + + fmt.Printf("Client configured with HTTPS proxy\n") + _ = client + + // Output: Client configured with HTTPS proxy +} + +// ExampleClientBuilder_WithProxy_authentication demonstrates using a proxy with authentication. +func ExampleClientBuilder_WithProxy_authentication() { + // Create an HTTP client with authenticated proxy + // Proxy credentials are included in the URL + client := httpx.NewClientBuilder(). + WithProxy("http://username:password@proxy.example.com:8080"). + WithTimeout(10 * time.Second). + Build() + + fmt.Printf("Client configured with authenticated proxy\n") + _ = client + + // Output: Client configured with authenticated proxy +} + +// ExampleNewGenericClient_withProxy demonstrates using a proxy with the generic client. +func ExampleNewGenericClient_withProxy() { + type APIResponse struct { + Message string `json:"message"` + Status string `json:"status"` + } + + // Create a generic client with proxy configuration + client := httpx.NewGenericClient[APIResponse]( + httpx.WithProxy[APIResponse]("http://proxy.example.com:8080"), + httpx.WithTimeout[APIResponse](10*time.Second), + httpx.WithMaxRetries[APIResponse](3), + ) + + fmt.Printf("Generic client configured with proxy\n") + _ = client + + // Output: Generic client configured with proxy +} + +// ExampleNewGenericClient_withProxy_combined demonstrates combining proxy with other options. +func ExampleNewGenericClient_withProxy_combined() { + type User struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + } + + // Create a fully configured generic client with proxy + client := httpx.NewGenericClient[User]( + httpx.WithProxy[User]("http://proxy.example.com:8080"), + httpx.WithTimeout[User](15*time.Second), + httpx.WithMaxRetries[User](5), + httpx.WithRetryStrategy[User](httpx.JitterBackoffStrategy), + httpx.WithRetryBaseDelay[User](500*time.Millisecond), + httpx.WithRetryMaxDelay[User](30*time.Second), + ) + + fmt.Printf("Generic client with proxy and retry configuration\n") + _ = client + + // Output: Generic client with proxy and retry configuration +} + +// ExampleNewHTTPRetryClient_withProxy demonstrates using a proxy with the retry client. +func ExampleNewHTTPRetryClient_withProxy() { + // Create a retry client with proxy configuration + client := httpx.NewHTTPRetryClient( + httpx.WithProxyRetry("http://proxy.example.com:8080"), + httpx.WithMaxRetriesRetry(5), + httpx.WithRetryStrategyRetry( + httpx.ExponentialBackoff(500*time.Millisecond, 30*time.Second), + ), + ) + + fmt.Printf("Retry client configured with proxy\n") + _ = client + + // Output: Retry client configured with proxy +} + +// ExampleClientBuilder_WithProxy_disabling demonstrates how to disable proxy. +func ExampleClientBuilder_WithProxy_disabling() { + // Create an HTTP client without proxy (explicit disable) + client := httpx.NewClientBuilder(). + WithProxy(""). // Empty string disables proxy + WithTimeout(10 * time.Second). + Build() + + fmt.Printf("Client configured without proxy\n") + _ = client + + // Output: Client configured without proxy +} + +// ExampleNewGenericClient_withProxy_portVariations demonstrates different proxy port configurations. +func ExampleNewGenericClient_withProxy_portVariations() { + type Data struct { + Value string `json:"value"` + } + + // Example 1: Standard HTTP proxy on port 8080 + client1 := httpx.NewGenericClient[Data]( + httpx.WithProxy[Data]("http://proxy.example.com:8080"), + ) + + // Example 2: HTTPS proxy on port 3128 (common Squid port) + client2 := httpx.NewGenericClient[Data]( + httpx.WithProxy[Data]("https://proxy.example.com:3128"), + ) + + // Example 3: Custom port + client3 := httpx.NewGenericClient[Data]( + httpx.WithProxy[Data]("http://proxy.example.com:9090"), + ) + + fmt.Printf("Configured clients with different proxy ports\n") + _, _, _ = client1, client2, client3 + + // Output: Configured clients with different proxy ports +} diff --git a/http_client.go b/http_client.go index 1c365c3..49c4806 100644 --- a/http_client.go +++ b/http_client.go @@ -3,6 +3,7 @@ package httpx import ( "log/slog" "net/http" + "net/url" "time" ) @@ -116,6 +117,7 @@ type Client struct { retryBaseDelay time.Duration retryMaxDelay time.Duration disableKeepAlive bool + proxyURL string // Proxy URL (e.g., "http://proxy.example.com:8080") logger *slog.Logger // Optional logger (nil = no logging) } @@ -259,6 +261,16 @@ func (b *ClientBuilder) WithLogger(logger *slog.Logger) *ClientBuilder { return b } +// WithProxy sets the proxy URL for HTTP requests. +// The proxy URL should be in the format "http://proxy.example.com:8080" or "https://proxy.example.com:8080". +// Pass an empty string to disable proxy (default behavior). +// and returns the ClientBuilder for method chaining +func (b *ClientBuilder) WithProxy(proxyURL string) *ClientBuilder { + b.client.proxyURL = proxyURL + + return b +} + // Build creates and returns a new HTTP client with the specified settings // and retry strategy. The client works transparently, preserving any existing // headers in requests without requiring explicit configuration. @@ -370,6 +382,18 @@ func (b *ClientBuilder) Build() *http.Client { MaxIdleConnsPerHost: b.client.maxIdleConnsPerHost, } + // Configure proxy if set + if b.client.proxyURL != "" { + parsedProxyURL, err := url.Parse(b.client.proxyURL) + if err != nil { + if b.client.logger != nil { + b.client.logger.Warn("Failed to parse proxy URL, proceeding without proxy", "proxyURL", b.client.proxyURL, "error", err) + } + } else { + transport.Proxy = http.ProxyURL(parsedProxyURL) + } + } + // 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{ diff --git a/http_client_test.go b/http_client_test.go index fc6b764..ce1f5de 100644 --- a/http_client_test.go +++ b/http_client_test.go @@ -201,3 +201,100 @@ func TestClientBuilder_WithRetryStrategyAsString(t *testing.T) { }) } } + +func TestClientBuilder_WithProxy(t *testing.T) { + t.Run("Valid proxy URL", func(t *testing.T) { + builder := NewClientBuilder() + proxyURL := "http://proxy.example.com:8080" + + builder.WithProxy(proxyURL) + + assertEqual(t, proxyURL, builder.client.proxyURL) + }) + + t.Run("HTTPS proxy URL", func(t *testing.T) { + builder := NewClientBuilder() + proxyURL := "https://secure-proxy.example.com:3128" + + builder.WithProxy(proxyURL) + + assertEqual(t, proxyURL, builder.client.proxyURL) + }) + + t.Run("Empty proxy URL (disable proxy)", func(t *testing.T) { + builder := NewClientBuilder() + builder.WithProxy("") + + assertEqual(t, "", builder.client.proxyURL) + }) + + t.Run("Proxy with authentication", func(t *testing.T) { + builder := NewClientBuilder() + proxyURL := "http://user:pass@proxy.example.com:8080" + + builder.WithProxy(proxyURL) + + assertEqual(t, proxyURL, builder.client.proxyURL) + }) +} + +func TestClientBuilder_Build_WithProxy(t *testing.T) { + t.Run("Build with valid proxy", func(t *testing.T) { + builder := NewClientBuilder() + proxyURL := "http://proxy.example.com:8080" + + client := builder.WithProxy(proxyURL).Build() + + assertNotNil(t, client) + assertNotNil(t, client.Transport) + + // Verify that the transport has proxy configured + if rt, ok := client.Transport.(*retryTransport); ok { + if transport, ok := rt.Transport.(*http.Transport); ok { + assertNotNil(t, transport.Proxy) + } else { + t.Error("Expected *http.Transport") + } + } else { + t.Error("Expected *retryTransport") + } + }) + + t.Run("Build without proxy", func(t *testing.T) { + builder := NewClientBuilder() + + client := builder.Build() + + assertNotNil(t, client) + assertNotNil(t, client.Transport) + + // Verify that the transport has no proxy configured (nil) + if rt, ok := client.Transport.(*retryTransport); ok { + if transport, ok := rt.Transport.(*http.Transport); ok { + // Proxy should be nil (not configured) + if transport.Proxy != nil { + t.Error("Expected Proxy to be nil when not configured") + } + } + } + }) + + t.Run("Build with invalid proxy URL", func(t *testing.T) { + builder := NewClientBuilder() + invalidProxyURL := "://invalid-url" + + client := builder.WithProxy(invalidProxyURL).Build() + + // Should still build successfully, but proxy will be ignored + assertNotNil(t, client) + + // Verify that the transport has no proxy configured due to parse error + if rt, ok := client.Transport.(*retryTransport); ok { + if transport, ok := rt.Transport.(*http.Transport); ok { + if transport.Proxy != nil { + t.Error("Expected Proxy to be nil when invalid URL provided") + } + } + } + }) +} diff --git a/http_generic_client.go b/http_generic_client.go index 958bce9..4ad3ada 100644 --- a/http_generic_client.go +++ b/http_generic_client.go @@ -33,6 +33,7 @@ type GenericClient[T any] struct { retryMaxDelay *time.Duration retryStrategy *Strategy disableKeepAlive *bool + proxyURL *string // Proxy URL (e.g., "http://proxy.example.com:8080") logger *slog.Logger // Optional logger (nil = no logging) } @@ -140,6 +141,10 @@ func NewGenericClient[T any](options ...GenericClientOption[T]) *GenericClient[T builder.WithLogger(client.logger) } + if client.proxyURL != nil { + builder.WithProxy(*client.proxyURL) + } + client.httpClient = builder.Build() return client } @@ -261,6 +266,15 @@ func WithLogger[T any](logger *slog.Logger) GenericClientOption[T] { } } +// WithProxy sets the proxy URL for HTTP requests. +// The proxy URL should be in the format "http://proxy.example.com:8080" or "https://proxy.example.com:8080". +// Pass an empty string to disable proxy (default behavior). +func WithProxy[T any](proxyURL string) GenericClientOption[T] { + return func(c *GenericClient[T]) { + c.proxyURL = &proxyURL + } +} + // Execute performs an HTTP request and returns a typed response. // It executes the request, reads the response body, // and unmarshals the JSON response into the generic type T. diff --git a/http_generic_client_test.go b/http_generic_client_test.go index 13132da..16b5d1a 100644 --- a/http_generic_client_test.go +++ b/http_generic_client_test.go @@ -725,3 +725,123 @@ func TestGenericClient_OptionsIntegration(t *testing.T) { } }) } + +func TestGenericClient_WithProxy(t *testing.T) { + t.Run("Create client with proxy URL", func(t *testing.T) { + proxyURL := "http://proxy.example.com:8080" + client := NewGenericClient[User]( + WithProxy[User](proxyURL), + ) + + if client == nil { + t.Fatal("NewGenericClient returned nil") + } + + if client.proxyURL == nil { + t.Fatal("proxyURL should be set") + } + + if *client.proxyURL != proxyURL { + t.Errorf("Expected proxy URL %s, got %s", proxyURL, *client.proxyURL) + } + }) + + t.Run("Create client with HTTPS proxy", func(t *testing.T) { + proxyURL := "https://secure-proxy.example.com:3128" + client := NewGenericClient[User]( + WithProxy[User](proxyURL), + ) + + if client == nil { + t.Fatal("NewGenericClient returned nil") + } + + if client.proxyURL == nil || *client.proxyURL != proxyURL { + t.Errorf("Expected proxy URL %s", proxyURL) + } + }) + + t.Run("Create client without proxy", func(t *testing.T) { + client := NewGenericClient[User]() + + if client == nil { + t.Fatal("NewGenericClient returned nil") + } + + if client.proxyURL != nil { + t.Error("proxyURL should be nil when not configured") + } + }) + + t.Run("Create client with empty proxy (disable)", func(t *testing.T) { + client := NewGenericClient[User]( + WithProxy[User](""), + ) + + if client == nil { + t.Fatal("NewGenericClient returned nil") + } + + if client.proxyURL == nil { + t.Fatal("proxyURL should be set (even if empty)") + } + + if *client.proxyURL != "" { + t.Error("Expected empty proxy URL") + } + }) + + t.Run("Build HTTP client with proxy", func(t *testing.T) { + proxyURL := "http://proxy.example.com:8080" + client := NewGenericClient[User]( + WithProxy[User](proxyURL), + ) + + if client.httpClient == nil { + t.Fatal("httpClient should be initialized") + } + + // Verify the transport has proxy configured + if httpClient, ok := client.httpClient.(*http.Client); ok { + if rt, ok := httpClient.Transport.(*retryTransport); ok { + if transport, ok := rt.Transport.(*http.Transport); ok { + if transport.Proxy == nil { + t.Error("Expected Proxy to be configured") + } + } else { + t.Error("Expected *http.Transport") + } + } else { + t.Error("Expected *retryTransport") + } + } else { + t.Error("Expected *http.Client") + } + }) + + t.Run("Combine proxy with other options", func(t *testing.T) { + proxyURL := "http://proxy.example.com:8080" + client := NewGenericClient[User]( + WithProxy[User](proxyURL), + WithTimeout[User](10*time.Second), + WithMaxRetries[User](3), + WithRetryStrategy[User](ExponentialBackoffStrategy), + ) + + if client == nil { + t.Fatal("NewGenericClient returned nil") + } + + if client.proxyURL == nil || *client.proxyURL != proxyURL { + t.Error("Proxy URL should be configured") + } + + if client.timeout == nil || *client.timeout != 10*time.Second { + t.Error("Timeout should be configured") + } + + if client.maxRetries == nil || *client.maxRetries != 3 { + t.Error("MaxRetries should be configured") + } + }) +} diff --git a/http_retrier.go b/http_retrier.go index 6968b6c..0096a23 100644 --- a/http_retrier.go +++ b/http_retrier.go @@ -7,6 +7,7 @@ import ( "log/slog" "math/rand" "net/http" + "net/url" "time" ) @@ -198,6 +199,7 @@ type retryClientConfig struct { maxRetries int strategy RetryStrategy baseTransport http.RoundTripper + proxyURL string // Proxy URL (e.g., "http://proxy.example.com:8080") logger *slog.Logger } @@ -231,6 +233,15 @@ func WithLoggerRetry(logger *slog.Logger) RetryClientOption { } } +// WithProxyRetry sets the proxy URL for the retry client. +// The proxy URL should be in the format "http://proxy.example.com:8080" or "https://proxy.example.com:8080". +// Pass an empty string to disable proxy (default behavior). +func WithProxyRetry(proxyURL string) RetryClientOption { + return func(c *retryClientConfig) { + c.proxyURL = proxyURL + } +} + // NewHTTPRetryClient creates a new http.Client configured with the retry transport. // Use the provided options to customize the retry behavior. // By default, it uses 3 retries with exponential backoff strategy and no logging. @@ -250,6 +261,30 @@ func NewHTTPRetryClient(options ...RetryClientOption) *http.Client { config.baseTransport = http.DefaultTransport } + // Configure proxy if provided + if config.proxyURL != "" { + // If base transport is http.Transport, we need to clone it to avoid mutating shared transport + if transport, ok := config.baseTransport.(*http.Transport); ok { + // Clone the transport to avoid mutating the original + clonedTransport := transport.Clone() + + parsedProxyURL, err := url.Parse(config.proxyURL) + if err != nil { + if config.logger != nil { + config.logger.Warn("Failed to parse proxy URL, proceeding without proxy", "proxyURL", config.proxyURL, "error", err) + } + } else { + clonedTransport.Proxy = http.ProxyURL(parsedProxyURL) + config.baseTransport = clonedTransport + } + } else { + // If custom transport is provided that's not *http.Transport, log a warning + if config.logger != nil { + config.logger.Warn("Custom transport provided; proxy configuration ignored. Configure proxy on your custom transport directly.") + } + } + } + if config.strategy == nil { config.strategy = ExponentialBackoff(DefaultBaseDelay, DefaultMaxDelay) } diff --git a/http_retrier_test.go b/http_retrier_test.go index 854c718..8190cc2 100644 --- a/http_retrier_test.go +++ b/http_retrier_test.go @@ -682,3 +682,140 @@ func TestRetryTransport_RequestBodyGetBodyError(t *testing.T) { t.Errorf("Expected only 1 attempt before GetBody error, got %d", atomic.LoadInt32(&attempts)) } } + +func TestNewHTTPRetryClient_WithProxy(t *testing.T) { + t.Run("Create retry client with proxy", func(t *testing.T) { + proxyURL := "http://proxy.example.com:8080" + client := NewHTTPRetryClient( + WithProxyRetry(proxyURL), + ) + + if client == nil { + t.Fatal("NewHTTPRetryClient returned nil") + } + + // Verify the transport has proxy configured + if rt, ok := client.Transport.(*retryTransport); ok { + if transport, ok := rt.Transport.(*http.Transport); ok { + if transport.Proxy == nil { + t.Error("Expected Proxy to be configured") + } + } else { + t.Error("Expected *http.Transport") + } + } else { + t.Error("Expected *retryTransport") + } + }) + + t.Run("Create retry client with HTTPS proxy", func(t *testing.T) { + proxyURL := "https://secure-proxy.example.com:3128" + client := NewHTTPRetryClient( + WithProxyRetry(proxyURL), + WithMaxRetriesRetry(5), + ) + + if client == nil { + t.Fatal("NewHTTPRetryClient returned nil") + } + + if rt, ok := client.Transport.(*retryTransport); ok { + if rt.MaxRetries != 5 { + t.Errorf("Expected MaxRetries to be 5, got %d", rt.MaxRetries) + } + + if transport, ok := rt.Transport.(*http.Transport); ok { + if transport.Proxy == nil { + t.Error("Expected Proxy to be configured") + } + } + } + }) + + t.Run("Create retry client without proxy", func(t *testing.T) { + client := NewHTTPRetryClient() + + if client == nil { + t.Fatal("NewHTTPRetryClient returned nil") + } + + // Default transport should not have proxy configured + if rt, ok := client.Transport.(*retryTransport); ok { + if transport, ok := rt.Transport.(*http.Transport); ok { + // Note: http.DefaultTransport may have Proxy set to use environment variables + // so we just verify the client was created successfully + _ = transport.Proxy + } + } + }) + + t.Run("Invalid proxy URL", func(t *testing.T) { + invalidProxyURL := "://invalid-url" + client := NewHTTPRetryClient( + WithProxyRetry(invalidProxyURL), + ) + + // Client should still be created + if client == nil { + t.Fatal("NewHTTPRetryClient returned nil") + } + + // When invalid URL is provided, a new transport is created + // but proxy won't be properly configured (will fall back to ProxyFromEnvironment) + if rt, ok := client.Transport.(*retryTransport); ok { + if transport, ok := rt.Transport.(*http.Transport); ok { + // Transport is created, but proxy configuration failed gracefully + _ = transport.Proxy + } + } + }) + + t.Run("Proxy with custom base transport", func(t *testing.T) { + // Create a custom transport + customTransport := &http.Transport{ + MaxIdleConns: 50, + } + + proxyURL := "http://proxy.example.com:8080" + client := NewHTTPRetryClient( + WithBaseTransport(customTransport), + WithProxyRetry(proxyURL), + ) + + if client == nil { + t.Fatal("NewHTTPRetryClient returned nil") + } + + // Custom transport should have proxy configured + if rt, ok := client.Transport.(*retryTransport); ok { + if transport, ok := rt.Transport.(*http.Transport); ok { + if transport.MaxIdleConns != 50 { + t.Error("Expected custom transport to be used") + } + + if transport.Proxy == nil { + t.Error("Expected Proxy to be configured on custom transport") + } + } + } + }) + + t.Run("Empty proxy URL", func(t *testing.T) { + client := NewHTTPRetryClient( + WithProxyRetry(""), + ) + + if client == nil { + t.Fatal("NewHTTPRetryClient returned nil") + } + + // Empty proxy URL doesn't trigger proxy configuration + // Should use default transport + if rt, ok := client.Transport.(*retryTransport); ok { + // Verify transport exists (may be DefaultTransport) + if rt.Transport == nil { + t.Error("Expected Transport to be set") + } + } + }) +}