diff --git a/README.md b/README.md
index a826817..8eebb23 100644
--- a/README.md
+++ b/README.md
@@ -18,15 +18,19 @@ 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
+- π **Proxy Support** - HTTP/HTTPS proxy configuration with authentication (corporate proxies, authenticated proxies, and custom ports)
+- π **Optional Logging** - `log/slog` integration for observability (disabled by default)
+- π¦ **Zero External Dependencies** - Only the Go standard library, no third-party packages
## Table of Contents
- [Installation](#installation)
- [Upgrade](#upgrade)
- [Quick Start](#quick-start)
+- [Architecture](#architecture)
+ - [Component Overview](#component-overview)
+ - [Request Lifecycle](#request-lifecycle)
+ - [Composition Model](#composition-model)
- [Features](#features)
- [Request Builder](#request-builder)
- [Generic HTTP Client](#generic-http-client)
@@ -37,6 +41,8 @@ A comprehensive Go package for building and executing HTTP requests with advance
- [Examples](#examples)
- [API Reference](#api-reference)
- [Best Practices](#best-practices)
+- [Thread Safety](#thread-safety)
+- [Testing](#testing)
- [Contributing](#contributing)
## Installation
@@ -62,7 +68,7 @@ go get -u github.com/slashdevops/httpx
```go
import "github.com/slashdevops/httpx"
-// Build and execute a simple GET request
+// Build and validate a simple GET request
req, err := httpx.NewRequestBuilder("https://api.example.com").
WithMethodGET().
WithPath("/users/123").
@@ -73,11 +79,11 @@ if err != nil {
log.Fatal(err)
}
-// Use with standard http.Client
+// Use with the standard net/http client
resp, err := http.DefaultClient.Do(req)
```
-### Type-Safe Requests with Generic Client
+### Type-Safe Requests with the Generic Client
```go
type User struct {
@@ -93,7 +99,7 @@ client := httpx.NewGenericClient[User](
httpx.WithRetryStrategy[User](httpx.ExponentialBackoffStrategy),
)
-// Execute typed request
+// Execute a typed request (pass the full URL)
response, err := client.Get("https://api.example.com/users/123")
if err != nil {
log.Fatal(err)
@@ -106,21 +112,122 @@ fmt.Printf("User: %s (%s)\n", response.Data.Name, response.Data.Email)
### Request with Retry Logic
```go
-// Create client with retry logic
+// Build a *http.Client with retry logic
retryClient := httpx.NewClientBuilder().
WithMaxRetries(3).
WithRetryStrategy(httpx.ExponentialBackoffStrategy).
WithRetryBaseDelay(500 * time.Millisecond).
Build()
-// Use with generic client
+// Plug it into the generic client (takes precedence over other options)
client := httpx.NewGenericClient[User](
httpx.WithHTTPClient[User](retryClient),
- httpx.)
+)
-response, err := client.Get("/users/123")
+response, err := client.Get("https://api.example.com/users/123")
```
+## Architecture
+
+`httpx` is intentionally small and composable. It layers three independent building
+blocks on top of the standard library's `net/http`, so you can adopt as little or as
+much as you need.
+
+### Component Overview
+
+```mermaid
+graph TD
+ subgraph Build["π¨ Build a request"]
+ RB["RequestBuilder
fluent, validated"]
+ end
+
+ subgraph Execute["π― Execute with type safety"]
+ GC["GenericClient[T]
JSON marshal / unmarshal"]
+ end
+
+ subgraph Transport["βοΈ Configure transport & retries"]
+ CB["ClientBuilder"]
+ HRC["NewHTTPRetryClient"]
+ RT["retryTransport
http.RoundTripper"]
+ end
+
+ STD["net/http
http.Client + http.Transport"]
+
+ RB -->|"*http.Request"| GC
+ GC -->|"uses"| CB
+ CB -->|"*http.Client"| GC
+ CB -->|"builds"| RT
+ HRC -->|"builds"| RT
+ RT -->|"wraps"| STD
+
+ classDef build fill:#e1f5ff,stroke:#0288d1,color:#01579b
+ classDef exec fill:#e8f5e9,stroke:#43a047,color:#1b5e20
+ classDef transport fill:#fff3e0,stroke:#fb8c00,color:#e65100
+ classDef std fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
+
+ class RB build
+ class GC exec
+ class CB,HRC,RT transport
+ class STD std
+```
+
+| Component | Purpose | Returns |
+|-----------|---------|---------|
+| [`RequestBuilder`](#request-builder) | Construct and validate a request fluently | `*http.Request` |
+| [`GenericClient[T]`](#generic-http-client) | Execute requests with type-safe JSON (un)marshaling | `*Response[T]` |
+| [`ClientBuilder`](#client-builder) | Configure timeouts, pooling, proxy, and retries | `*http.Client` |
+| [`NewHTTPRetryClient`](#advanced-direct-retry-client) | Low-level retry transport wrapper | `*http.Client` |
+
+### Request Lifecycle
+
+The sequence below shows a request flowing through every layer, including a transient
+`503` that triggers one retry before succeeding.
+
+```mermaid
+sequenceDiagram
+ participant App as Your Code
+ participant RB as RequestBuilder
+ participant GC as GenericClient[T]
+ participant RT as retryTransport
+ participant Srv as Server
+
+ App->>RB: WithMethodGET().WithPath(...).Build()
+ RB-->>App: *http.Request (validated)
+ App->>GC: Execute(req)
+ GC->>RT: Do(req)
+
+ RT->>Srv: RoundTrip (attempt 1)
+ Srv-->>RT: 503 Service Unavailable
+ Note over RT: 5xx β wait backoff delay
+ RT->>Srv: RoundTrip (attempt 2)
+ Srv-->>RT: 200 OK + JSON
+
+ RT-->>GC: *http.Response
+ GC->>GC: json.Unmarshal into T
+ GC-->>App: *Response[T] (typed Data)
+```
+
+### Composition Model
+
+Every layer is optional and can be swapped. Use the builder without the client, the
+client without retries, or bring your own `HTTPClient` implementation for testing.
+
+```mermaid
+flowchart LR
+ A["RequestBuilder
(optional)"] -.->|"*http.Request"| B
+ B["GenericClient[T]
or plain *http.Client"]
+ C["Custom HTTPClient
(mocks, tracingβ¦)"] -.->|"WithHTTPClient"| B
+ D["ClientBuilder / NewHTTPRetryClient"] -.->|"*http.Client"| B
+
+ style A stroke-dasharray: 5 5
+ style C stroke-dasharray: 5 5
+ style D stroke-dasharray: 5 5
+```
+
+> The `HTTPClient` interface is just `Do(*http.Request) (*http.Response, error)` β the
+> same shape as `*http.Client`. Anything satisfying it (including a mock) can be injected
+> with `WithHTTPClient`, which makes the generic client trivial to unit test.
+
## Features
### Request Builder
@@ -130,14 +237,15 @@ The `RequestBuilder` provides a fluent, chainable API for constructing HTTP requ
#### Key Features
- β
HTTP methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE, CONNECT
-- β
Convenience methods for all standard HTTP methods (WithMethodGET, WithMethodPOST, WithMethodPUT, WithMethodDELETE, WithMethodPATCH, WithMethodHEAD, WithMethodOPTIONS, WithMethodTRACE, WithMethodCONNECT)
+- β
Convenience methods for every standard HTTP method (`WithMethodGET`, `WithMethodPOST`, `WithMethodPUT`, `WithMethodDELETE`, `WithMethodPATCH`, `WithMethodHEAD`, `WithMethodOPTIONS`, `WithMethodTRACE`, `WithMethodCONNECT`)
- β
Query parameters with automatic URL encoding
- β
Custom headers with validation
- β
Authentication (Basic Auth, Bearer Token)
-- β
Multiple body formats (JSON, string, bytes, io.Reader)
+- β
Multiple body formats (JSON, string, bytes, `io.Reader`)
- β
Context support for timeouts and cancellation
- β
Input validation with error accumulation
- β
Comprehensive error messages
+- β
Automatic `GetBody` wiring for replayable JSON bodies (so retries work)
#### Usage Example
@@ -161,15 +269,37 @@ if err != nil {
}
```
-#### Validation Features
-
-The RequestBuilder validates inputs and accumulates errors:
+#### Error Accumulation
+
+Unlike most builders that fail fast, `RequestBuilder` **accumulates** validation errors
+as you chain calls and reports them all at `Build()` time (or on demand via `HasErrors`
+/ `GetErrors`). This lets you surface every problem in a user-supplied request at once.
+
+```mermaid
+flowchart TD
+ Start(["NewRequestBuilder(baseURL)"]) --> Chain["Chain WithMethod / WithHeader /
WithQueryParam / WithBody β¦"]
+ Chain --> Valid{"Input valid?"}
+ Valid -->|"yes"| Store["Apply to builder state"]
+ Valid -->|"no"| Acc["Append to errors[]"]
+ Store --> More{"More calls?"}
+ Acc --> More
+ More -->|"yes"| Chain
+ More -->|"no"| Build["Build()"]
+ Build --> HasErr{"len(errors) > 0
or invalid state?"}
+ HasErr -->|"yes"| Err(["return nil, error
(all accumulated)"])
+ HasErr -->|"no"| Req(["return *http.Request, nil"])
+
+ classDef ok fill:#e8f5e9,stroke:#43a047,color:#1b5e20
+ classDef bad fill:#ffebee,stroke:#e53935,color:#b71c1c
+ class Req,Store ok
+ class Err,Acc bad
+```
```go
builder := httpx.NewRequestBuilder("https://api.example.com")
-builder.HTTPMethod("") // Error: empty method
-builder.WithHeader("", "value") // Error: empty header key
-builder.WithQueryParam("key=", "val") // Error: invalid character in key
+builder.WithMethod("") // Error: empty method
+builder.WithHeader("", "value") // Error: empty header key
+builder.WithQueryParam("key=", "val") // Error: invalid character in key
// Check for errors before building
if builder.HasErrors() {
@@ -178,7 +308,7 @@ if builder.HasErrors() {
}
}
-// Or let Build() report all errors
+// Or let Build() report all errors at once
req, err := builder.Build()
if err != nil {
// err contains all accumulated validation errors
@@ -192,27 +322,32 @@ if err != nil {
builder := httpx.NewRequestBuilder("https://api.example.com")
// Use builder
-req1, _ := builder.WithWithMethodGET().WithPath("/users").Build()
+req1, _ := builder.WithMethodGET().WithPath("/users").Build()
-// Reset and reuse
+// Reset and reuse for a fresh request
builder.Reset()
-req2, _ := builder.WithWithMethodPOST().WithPath("/posts").Build()
+req2, _ := builder.WithMethodPOST().WithPath("/posts").Build()
```
+> **Note:** A `RequestBuilder` is *not* safe for concurrent use. Create one per goroutine,
+> or build requests up front and share the resulting `*http.Request` values.
+
### Generic HTTP Client
-The `GenericClient` provides type-safe HTTP requests with automatic JSON marshaling and unmarshaling using Go generics.
+The `GenericClient[T]` provides type-safe HTTP requests with automatic JSON marshaling and unmarshaling using Go generics.
#### Key Features
-- π― Type-safe responses with automatic JSON unmarshaling
-- π Convenience methods: Get, Post, Put, Delete, Patch
-- π Execute method for use with RequestBuilder
-- π¦ ExecuteRaw for non-JSON responses
-- π Base URL resolution for relative paths
-- π Default headers applied to all requests
-- β Structured error responses
-- π Full integration with retry logic
+- π― Type-safe responses with automatic JSON unmarshaling into `T`
+- π Convenience methods: `Get`, `Post`, `Put`, `Delete`, `Patch`
+- π `Execute` / `Do` for use with `RequestBuilder`
+- π¦ `ExecuteRaw` for non-JSON responses (images, files, streams)
+- β Structured error responses (`*ErrorResponse`) for HTTP status >= 400
+- π Built-in retry logic (configured via options) or bring your own client
+- π§ͺ Injectable `HTTPClient` for easy mocking in tests
+
+> **URLs:** The convenience methods (`Get`, `Post`, β¦) take a **full URL**. For a shared
+> base URL plus paths, use [`RequestBuilder`](#request-builder) with `Execute`/`Do`.
#### Basic Usage
@@ -237,15 +372,17 @@ if err != nil {
}
fmt.Printf("Title: %s\n", response.Data.Title)
-// POST request
+// POST request with a body
newPost := Post{Title: "New Post", Body: "Content", UserID: 1}
postData, _ := json.Marshal(newPost)
-response, err = client.Post("https://api.example.com/posts", bytes.NewReader(postData))
+created, err := client.Post("https://api.example.com/posts", bytes.NewReader(postData))
```
#### With RequestBuilder
-Combine GenericClient with RequestBuilder for maximum flexibility:
+Combine `GenericClient` with `RequestBuilder` for maximum flexibility β the builder wires
+up authentication, headers, query params, and a replayable JSON body, and the client
+executes it with type safety:
```go
type User struct {
@@ -259,7 +396,7 @@ client := httpx.NewGenericClient[User](
httpx.WithMaxRetries[User](3),
)
-// Build complex request
+// Build a complex request
req, err := httpx.NewRequestBuilder("https://api.example.com").
WithMethodPOST().
WithPath("/users").
@@ -283,24 +420,42 @@ fmt.Printf("Created user ID: %d\n", response.Data.ID)
#### Error Handling
-The generic client returns structured errors:
+For any response with status code >= 400, the client returns a structured `*ErrorResponse`:
```go
-response, err := client.Get("/users/999999")
+response, err := client.Get("https://api.example.com/users/999999")
if err != nil {
- // Check if it's an API error
+ // Check if it's a structured API error
if apiErr, ok := err.(*httpx.ErrorResponse); ok {
fmt.Printf("API Error %d: %s\n", apiErr.StatusCode, apiErr.Message)
- // StatusCode: 404
- // Message: "User not found"
+ // e.g. StatusCode: 404, Message: "User not found"
} else {
- // Network error, parsing error, etc.
+ // Network error, parsing error, retries exhausted, etc.
log.Printf("Request failed: %v\n", err)
}
return
}
```
+#### Non-JSON Responses
+
+Use `ExecuteRaw` when the response isn't JSON (binary downloads, streaming, etc.). It
+returns the raw `*http.Response` without unmarshaling β remember to close the body:
+
+```go
+client := httpx.NewGenericClient[any]()
+
+req, _ := http.NewRequest(http.MethodGet, "https://example.com/image.png", nil)
+
+resp, err := client.ExecuteRaw(req)
+if err != nil {
+ log.Fatal(err)
+}
+defer resp.Body.Close()
+
+fmt.Printf("Content-Type: %s\n", resp.Header.Get("Content-Type"))
+```
+
#### Multiple Typed Clients
Use different clients for different response types:
@@ -317,22 +472,72 @@ postClient := httpx.NewGenericClient[Post](
httpx.WithTimeout[Post](10 * time.Second),
)
-// Fetch user
-userResp, _ := userClient.Get("/users/1")
-
-// Fetch user's posts
-postsResp, _ := postClient.Get(fmt.Sprintf("/users/%d/posts", userResp.Data.ID))
+// Fetch a user, then that user's posts
+userResp, _ := userClient.Get("https://api.example.com/users/1")
+postsResp, _ := postClient.Get(fmt.Sprintf("https://api.example.com/users/%d/posts", userResp.Data.ID))
```
### Retry Logic
-The package provides transparent retry logic with configurable strategies.
+The package provides transparent retry logic with configurable strategies. Retries
+preserve all headers and authentication, and replay the request body via `GetBody`.
+
+#### What Gets Retried?
+
+```mermaid
+flowchart TD
+ Req["Send request (attempt n)"] --> Err{"Transport error?"}
+ Err -->|"yes
(connection, timeout)"| Retryable
+ Err -->|"no"| Code{"Status code?"}
+ Code -->|"5xx (500β599)"| Retryable
+ Code -->|"429 Too Many Requests"| Retryable
+ Code -->|"2xx / 3xx / other 4xx"| Success(["Return response
β
no retry"])
+
+ Retryable{"Attempts left?"} -->|"yes"| Wait["Wait backoff delay
(respects context)"]
+ Retryable -->|"no"| Fail(["Return last error
β retries exhausted"])
+ Wait --> Req
+
+ classDef ok fill:#e8f5e9,stroke:#43a047,color:#1b5e20
+ classDef bad fill:#ffebee,stroke:#e53935,color:#b71c1c
+ classDef warn fill:#fff3e0,stroke:#fb8c00,color:#e65100
+ class Success ok
+ class Fail bad
+ class Wait,Retryable warn
+```
+
+**Retried automatically:**
+
+- Network errors (connection failures, timeouts)
+- HTTP 5xx server errors (500β599)
+- HTTP 429 (Too Many Requests)
+
+**Not retried:**
+
+- HTTP 4xx client errors (except 429)
+- HTTP 2xx / 3xx responses
+- Requests without a `GetBody` (non-replayable bodies)
+
+> **Context awareness:** If the request's context is cancelled or its deadline expires
+> (including when `http.Client.Timeout` fires), retries stop immediately and the original
+> error is returned β no misleading "retry cancelled" churn.
#### Retry Strategies
+The three built-in strategies differ in how the delay grows between attempts:
+
+```mermaid
+xychart-beta
+ title "Delay per retry attempt (base=500ms, max=10s)"
+ x-axis "Attempt" [1, 2, 3, 4, 5]
+ y-axis "Delay (ms)" 0 --> 8500
+ line "Exponential" [500, 1000, 2000, 4000, 8000]
+ line "Fixed" [500, 500, 500, 500, 500]
+ line "Jitter (avg)" [625, 1250, 2500, 5000, 7500]
+```
+
##### Exponential Backoff (Recommended)
-Doubles the wait time between retries:
+Doubles the wait time between retries, capped at `maxDelay`:
```go
client := httpx.NewClientBuilder().
@@ -343,7 +548,7 @@ client := httpx.NewClientBuilder().
Build()
```
-Wait times: 500ms β 1s β 2s β 4s (capped at maxDelay)
+Wait times: `500ms β 1s β 2s β 4s` (capped at `maxDelay`)
##### Fixed Delay
@@ -357,11 +562,12 @@ client := httpx.NewClientBuilder().
Build()
```
-Wait times: 1s β 1s β 1s
+Wait times: `1s β 1s β 1s`
##### Jitter Backoff
-Adds randomization to exponential backoff to prevent thundering herd:
+Adds randomization on top of exponential backoff to prevent the *thundering herd*
+problem when many clients retry in lockstep:
```go
client := httpx.NewClientBuilder().
@@ -372,43 +578,55 @@ client := httpx.NewClientBuilder().
Build()
```
-Wait times: Random between 0-500ms β 0-1s β 0-2s
-
-#### What Gets Retried?
-
-The retry logic automatically retries:
-
-- Network errors (connection failures, timeouts)
-- HTTP 5xx server errors (500-599)
-- HTTP 429 (Too Many Requests)
-
-Does NOT retry:
+Wait times: exponential base plus a random `0 β base/2` jitter per attempt.
-- HTTP 4xx client errors (except 429)
-- HTTP 2xx/3xx successful responses
-- Requests without GetBody (non-replayable requests)
+#### Retry with the Generic Client
-#### Retry with Generic Client
+You can configure retries directly on the generic client, or build a `*http.Client`
+and inject it:
```go
-// Create retry client
+// Option A: configure retries directly on the generic client
+client := httpx.NewGenericClient[User](
+ httpx.WithMaxRetries[User](3),
+ httpx.WithRetryStrategy[User](httpx.ExponentialBackoffStrategy),
+ httpx.WithRetryBaseDelay[User](500*time.Millisecond),
+)
+
+// Option B: build a client and inject it (takes precedence)
retryClient := httpx.NewClientBuilder().
WithMaxRetries(3).
WithRetryStrategy(httpx.ExponentialBackoffStrategy).
Build()
-// Use with generic client
-client := httpx.NewGenericClient[User](
+client = httpx.NewGenericClient[User](
httpx.WithHTTPClient[User](retryClient),
- httpx.)
+)
-// Requests automatically retry on failure
-response, err := client.Get("/users/1")
+response, err := client.Get("https://api.example.com/users/1")
+```
+
+#### Advanced: Direct Retry Client
+
+For full control over the transport, `NewHTTPRetryClient` returns a plain `*http.Client`
+whose transport applies retries. The `RetryStrategy` here is a function you pass directly:
+
+```go
+client := httpx.NewHTTPRetryClient(
+ httpx.WithMaxRetriesRetry(5),
+ httpx.WithRetryStrategyRetry(
+ httpx.ExponentialBackoff(500*time.Millisecond, 30*time.Second),
+ ),
+ httpx.WithBaseTransport(http.DefaultTransport),
+)
+
+resp, err := client.Get("https://api.example.com/data")
```
### Client Builder
-The `ClientBuilder` provides fine-grained control over HTTP client configuration.
+The `ClientBuilder` provides fine-grained control over HTTP client configuration and
+returns a ready-to-use `*http.Client`.
#### Configuration Options
@@ -431,39 +649,46 @@ client := httpx.NewClientBuilder().
WithRetryBaseDelay(500 * time.Millisecond).
WithRetryMaxDelay(10 * time.Second).
+ // Proxy (optional)
+ WithProxy("http://proxy.example.com:8080").
+
+ // Logging (optional)
+ WithLogger(logger).
+
Build()
```
#### Default Values
+The builder validates every setting and silently falls back to the default when a value
+is out of range (logging a warning if a logger is configured).
+
| Setting | Default | Valid Range |
|---------|---------|-------------|
-| Timeout | 5s | 1s - 600s |
-| MaxRetries | 3 | 1 - 10 |
-| RetryBaseDelay | 500ms | 300ms - 5s |
-| RetryMaxDelay | 10s | 300ms - 120s |
-| MaxIdleConns | 100 | 1 - 200 |
-| IdleConnTimeout | 90s | 1s - 120s |
-| TLSHandshakeTimeout | 10s | 1s - 15s |
-
-The builder validates all settings and uses defaults for out-of-range values.
+| Timeout | 5s | 1s β 600s |
+| MaxRetries | 3 | 1 β 10 |
+| RetryBaseDelay | 500ms | 300ms β 5s |
+| RetryMaxDelay | 10s | 300ms β 120s |
+| MaxIdleConns | 100 | 1 β 200 |
+| MaxIdleConnsPerHost | 100 | 1 β 200 |
+| IdleConnTimeout | 90s | 1s β 120s |
+| TLSHandshakeTimeout | 10s | 1s β 15s |
+| ExpectContinueTimeout | 1s | 1s β 5s |
### 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.
+`httpx` provides comprehensive HTTP/HTTPS proxy support across all client types. Route
+requests through corporate firewalls, load balancers, or testing proxies.
#### Key Features
- β
HTTP and HTTPS proxy support
-- π Proxy authentication (username/password)
+- π Proxy authentication (username/password embedded in the URL)
- π Works with retry logic
-- π― Compatible with all client types
-- π Full URL or host:port formats
-- π Graceful fallback on invalid URLs
-
-#### Basic Usage
+- π― Compatible with `ClientBuilder`, `GenericClient`, and `NewHTTPRetryClient`
+- π Graceful fallback on invalid URLs (a warning is logged if a logger is set)
-##### With ClientBuilder
+#### With ClientBuilder
```go
// HTTP proxy
@@ -478,15 +703,9 @@ client := httpx.NewClientBuilder().
Build()
```
-##### With GenericClient
+#### 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),
@@ -496,7 +715,7 @@ client := httpx.NewGenericClient[User](
response, err := client.Get("https://api.example.com/users/1")
```
-##### With Retry Client
+#### With the Retry Client
```go
client := httpx.NewHTTPRetryClient(
@@ -518,7 +737,8 @@ client := httpx.NewClientBuilder().
Build()
```
-**Security Note:** For production, consider using environment variables or secret management:
+**Security note:** For production, source credentials from the environment or a secret
+manager rather than hard-coding them:
```go
proxyURL := fmt.Sprintf("http://%s:%s@%s:%s",
@@ -542,88 +762,31 @@ client := httpx.NewClientBuilder().
#### Disable Proxy
-Override environment proxy settings by passing an empty string:
+Pass an empty string to disable proxying (overriding any `HTTP_PROXY`/`HTTPS_PROXY`
+environment variables):
```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()
+### Logging
-// Client builds successfully, but proxy is not configured
-// Warning logged if logger is provided
-```
+`httpx` supports optional logging using Go's standard `log/slog`. **Logging is disabled
+by default** to keep HTTP operations clean and silent. Enable it to gain observability
+into retries and failures. When no logger is set, the overhead is a single `nil` check.
-### Logging
+Loggers are wired in per client type:
-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.
+| Client | Option |
+|--------|--------|
+| `ClientBuilder` | `WithLogger(logger)` |
+| `GenericClient[T]` | `WithLogger[T](logger)` |
+| `NewHTTPRetryClient` | `WithLoggerRetry(logger)` |
#### Quick Start
-##### Basic Usage
-
```go
import (
"log/slog"
@@ -637,21 +800,16 @@ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelWarn,
}))
-// Use with ClientBuilder
+// Wire it into any client type
client := httpx.NewClientBuilder().
WithMaxRetries(3).
- WithLogger(logger). // Enable logging
+ WithLogger(logger).
Build()
```
-##### With Generic Client
+With the generic client:
```go
-type User struct {
- ID int `json:"id"`
- Name string `json:"name"`
-}
-
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
client := httpx.NewGenericClient[User](
@@ -660,147 +818,46 @@ client := httpx.NewGenericClient[User](
)
```
-##### With NewHTTPRetryClient
-
-```go
-logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
-
-client := httpx.NewHTTPRetryClient(
- httpx.WithMaxRetriesRetry(3),
- httpx.WithRetryStrategyRetry(httpx.ExponentialBackoff(500*time.Millisecond, 10*time.Second)),
- httpx.WithLoggerRetry(logger),
-)
-```
-
#### What Gets Logged
-##### Retry Attempts (Warn Level)
-
-When a request fails and is being retried:
+**Retry attempts (WARN level)** β emitted each time a request fails and is retried:
-```
+```text
time=2026-01-17T21:00:00.000+00:00 level=WARN msg="HTTP request returned server error, retrying" attempt=1 max_retries=3 delay=500ms status_code=500 url=https://api.example.com/users method=GET
```
-Attributes logged:
-
-- `attempt`: Current retry attempt number (1-indexed)
-- `max_retries`: Maximum number of retries configured
-- `delay`: How long the client will wait before retrying
-- `status_code`: HTTP status code (for server errors) OR
-- `error`: Error message (for network/connection errors)
-- `url`: Full request URL
-- `method`: HTTP method (GET, POST, etc.)
-
-##### All Retries Failed (Error Level)
-
-When all retry attempts are exhausted:
+**All retries failed (ERROR level)** β emitted when every attempt is exhausted:
-```
+```text
time=2026-01-17T21:00:00.500+00:00 level=ERROR msg="All retry attempts failed" attempts=4 status_code=503 url=https://api.example.com/users method=GET
```
-Attributes logged:
-
-- `attempts`: Total number of attempts made (including initial request)
-- `status_code` OR `error`: Final failure reason
-- `url`: Full request URL
-- `method`: HTTP method
-
-#### Logger Configuration
-
-##### Log Levels
-
-Choose the appropriate log level based on your needs:
-
-```go
-// Only log final failures (recommended for production)
-logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
- Level: slog.LevelError,
-}))
-
-// Log all retry attempts (useful for debugging)
-logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
- Level: slog.LevelWarn,
-}))
-
-// Log everything including debug info from other packages
-logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
- Level: slog.LevelDebug,
-}))
-```
-
-##### Output Formats
+The `GenericClient` additionally logs request/response details (method, URL, headers,
+body) at **DEBUG** level, which is useful when troubleshooting a specific call.
-###### Text Format (Development)
+#### Output Formats
-Best for human readability during development:
+Choose text for development readability, JSON for production log aggregation:
```go
+// Text (development)
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelWarn,
}))
-```
-Output:
-
-```
-time=2026-01-17T21:00:00.000+00:00 level=WARN msg="HTTP request returned server error, retrying" attempt=1 max_retries=3 delay=500ms status_code=500
-```
-
-###### JSON Format (Production)
-
-Best for structured logging and log aggregation:
-
-```go
+// JSON (production)
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
- Level: slog.LevelError,
-}))
-```
-
-Output:
-
-```json
-{"time":"2026-01-17T21:00:00.000Z","level":"ERROR","msg":"All retry attempts failed","attempts":4,"status_code":503,"url":"https://api.example.com/users","method":"GET"}
-```
-
-##### Writing to Files
-
-```go
-logFile, err := os.OpenFile("http.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
-if err != nil {
- log.Fatal(err)
-}
-defer logFile.Close()
-
-logger := slog.New(slog.NewJSONHandler(logFile, &slog.HandlerOptions{
- Level: slog.LevelWarn,
+ Level: slog.LevelError, // only final failures
}))
```
#### Logging Best Practices
-1. **Default to No Logging**: Keep logging disabled in production unless actively troubleshooting:
-
- ```go
- // Production - no logging (default)
- client := httpx.NewClientBuilder().
- WithMaxRetries(3).
- Build() // No WithLogger() call = no logging
- ```
-
-2. **Use Structured Logging in Production**: JSON format is machine-readable and works well with log aggregators:
-
- ```go
- logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
- Level: slog.LevelError, // Only final failures
- }))
- ```
-
-3. **Enable for Specific Troubleshooting**: Turn on logging temporarily when investigating issues:
+1. **Default to no logging** in production unless actively troubleshooting.
+2. **Use JSON** for machine-readable logs that play well with aggregators.
+3. **Enable conditionally** via an environment variable when investigating issues:
```go
- // Temporarily enable for debugging
var logger *slog.Logger
if os.Getenv("DEBUG_HTTP") != "" {
logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
@@ -810,219 +867,31 @@ logger := slog.New(slog.NewJSONHandler(logFile, &slog.HandlerOptions{
client := httpx.NewClientBuilder().
WithMaxRetries(3).
- WithLogger(logger). // Will be nil if not debugging
+ WithLogger(logger). // nil when not debugging = no logging
Build()
```
-4. **Add Context with Attributes**: Enhance logs with additional context:
+4. **Add context** with `slog`'s `With` to tag a client's traffic:
```go
- // Create logger with service context
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)).
- With("service", "api-client").
- With("version", "1.0.0")
-
- client := httpx.NewClientBuilder().
- WithLogger(logger).
- Build()
- ```
-
-5. **Different Loggers for Different Clients**: Use separate loggers for different clients to distinguish traffic:
-
- ```go
- // User service client
- userLogger := slog.New(slog.NewJSONHandler(os.Stderr, nil)).
- With("client", "user-service")
- userClient := httpx.NewClientBuilder().
- WithLogger(userLogger).
- Build()
-
- // Payment service client
- paymentLogger := slog.New(slog.NewJSONHandler(os.Stderr, nil)).
- With("client", "payment-service")
- paymentClient := httpx.NewClientBuilder().
- WithLogger(paymentLogger).
- Build()
- ```
-
-#### Performance Considerations
-
-- **Minimal Overhead**: When logging is disabled (logger is `nil`), the overhead is just a simple nil check
-- **No Allocations**: Log statements use slog's efficient attribute system
-- **Deferred Work**: The logger only formats messages if the log level is enabled
-
-#### Disabling Logging
-
-Simply pass `nil` or omit the logger:
-
-```go
-// Explicitly pass nil
-client := httpx.NewClientBuilder().
- WithLogger(nil). // No logging
- Build()
-
-// Or just don't call WithLogger
-client := httpx.NewClientBuilder().
- WithMaxRetries(3).
- Build() // No logging (default)
-```
-
-#### Migration Guide
-
-If you have existing code without logging, no changes are needed. The feature is fully backward compatible:
-
-```go
-// Old code - still works, no logging
-client := httpx.NewClientBuilder().
- WithMaxRetries(3).
- Build()
-
-// New code - add logging when needed
-logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
-client := httpx.NewClientBuilder().
- WithMaxRetries(3).
- WithLogger(logger). // Just add this line
- Build()
-```
-
-#### Logging Examples
-
-##### Example 1: Development Debugging
-
-```go
-package main
-
-import (
- "log/slog"
- "os"
- "time"
-
- "github.com/slashdevops/httpx"
-)
-
-func main() {
- // Text output with warn level for debugging
- logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
- Level: slog.LevelWarn,
- }))
-
- client := httpx.NewClientBuilder().
- WithMaxRetries(3).
- WithRetryBaseDelay(500 * time.Millisecond).
- WithLogger(logger).
- Build()
-
- // You'll see retry attempts in the console
- resp, err := client.Get("https://api.example.com/flaky-endpoint")
- if err != nil {
- log.Fatal(err)
- }
- defer resp.Body.Close()
-}
-```
-
-##### Example 2: Production Monitoring
-
-```go
-package main
-
-import (
- "log/slog"
- "os"
-
- "github.com/slashdevops/httpx"
-)
-
-func main() {
- // JSON output, only errors, to stderr
- logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
- Level: slog.LevelError,
- })).With(
- "service", "payment-processor",
- "environment", "production",
- )
-
- client := httpx.NewClientBuilder().
- WithMaxRetries(3).
- WithLogger(logger).
- Build()
-
- // Only final failures will be logged
- resp, err := client.Get("https://payment-api.example.com/status")
- // ...
-}
-```
-
-##### Example 3: Conditional Logging
-
-```go
-package main
-
-import (
- "log/slog"
- "os"
-
- "github.com/slashdevops/httpx"
-)
-
-func createClient() *http.Client {
- var logger *slog.Logger
-
- // Only enable logging if DEBUG environment variable is set
- if os.Getenv("DEBUG") != "" {
- logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
- Level: slog.LevelDebug,
- }))
- }
-
- return httpx.NewClientBuilder().
- WithMaxRetries(3).
- WithLogger(logger). // Will be nil in production
- Build()
-}
-```
-
-#### Troubleshooting
-
-##### Not Seeing Any Logs?
-
-1. **Check logger level**: Make sure the level is set to at least `LevelWarn`:
-
- ```go
- logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
- Level: slog.LevelWarn, // Not Info or Debug
- }))
+ With("service", "api-client", "version", "1.0.0")
```
-2. **Verify logger is passed**: Make sure you called `WithLogger()`:
-
- ```go
- client := httpx.NewClientBuilder().
- WithLogger(logger). // Don't forget this!
- Build()
- ```
-
-3. **Check if retries are happening**: Logs only appear when requests fail and retry. Successful first attempts don't log.
-
-##### Too Many Logs?
-
-1. **Increase log level** to `LevelError` to only see final failures
-2. **Disable logging** in production environments where retry behavior is well understood
-3. **Use sampling** if your log aggregation system supports it
-
-#### Logging Summary
-
-The logging feature in httpx provides:
+## Examples
-- β
**Optional** - Disabled by default, zero overhead when not in use
-- β
**Standard** - Uses Go's `log/slog` package
-- β
**Flexible** - Configurable output format, level, and destination
-- β
**Informative** - Rich attributes for debugging and monitoring
-- β
**Backward Compatible** - Existing code works without changes
+Runnable examples live alongside the source as Go [example tests](https://pkg.go.dev/testing#hdr-Examples):
-Enable it when you need visibility, keep it off for clean, silent operations.
+| File | Covers |
+|------|--------|
+| [example_request_builder_test.go](example_request_builder_test.go) | Building and validating requests |
+| [example_generic_client_test.go](example_generic_client_test.go) | Type-safe requests, error handling, multiple clients |
+| [example_http_client_test.go](example_http_client_test.go) | `ClientBuilder` configuration |
+| [example_http_retrier_test.go](example_http_retrier_test.go) | Retry strategies and the direct retry client |
+| [example_proxy_test.go](example_proxy_test.go) | Proxy configuration |
+| [example_logger_test.go](example_logger_test.go) | `slog` logging |
-## Examples
+Browse them all on [pkg.go.dev](https://pkg.go.dev/github.com/slashdevops/httpx#pkg-examples).
### Complete Example: CRUD Operations
@@ -1045,35 +914,32 @@ type Todo struct {
}
func main() {
- // Create retry client
+ const baseURL = "https://jsonplaceholder.typicode.com"
+
+ // Build a *http.Client with retries and inject it into the typed client
retryClient := httpx.NewClientBuilder().
WithMaxRetries(3).
WithRetryStrategy(httpx.ExponentialBackoffStrategy).
WithTimeout(10 * time.Second).
Build()
- // Create typed client
client := httpx.NewGenericClient[Todo](
httpx.WithHTTPClient[Todo](retryClient),
- httpx. httpx. )
+ )
// GET - Read
fmt.Println("Fetching todo...")
- todo, err := client.Get("/todos/1")
+ todo, err := client.Get(baseURL + "/todos/1")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Todo: %s (completed: %v)\n", todo.Data.Title, todo.Data.Completed)
- // POST - Create
+ // POST - Create (build the request with RequestBuilder)
fmt.Println("\nCreating new todo...")
- newTodo := Todo{
- Title: "Learn httputils",
- Completed: false,
- UserID: 1,
- }
+ newTodo := Todo{Title: "Learn httpx", Completed: false, UserID: 1}
- req, _ := httpx.NewRequestBuilder("https://jsonplaceholder.typicode.com").
+ req, _ := httpx.NewRequestBuilder(baseURL).
WithMethodPOST().
WithPath("/todos").
WithContentType("application/json").
@@ -1091,7 +957,7 @@ func main() {
updateTodo := created.Data
updateTodo.Completed = true
- req, _ = httpx.NewRequestBuilder("https://jsonplaceholder.typicode.com").
+ req, _ = httpx.NewRequestBuilder(baseURL).
WithMethodPUT().
WithPath(fmt.Sprintf("/todos/%d", updateTodo.ID)).
WithContentType("application/json").
@@ -1106,7 +972,7 @@ func main() {
// DELETE
fmt.Println("\nDeleting todo...")
- deleteResp, err := client.Delete(fmt.Sprintf("/todos/%d", updateTodo.ID))
+ deleteResp, err := client.Delete(fmt.Sprintf("%s/todos/%d", baseURL, updateTodo.ID))
if err != nil {
log.Fatal(err)
}
@@ -1125,42 +991,37 @@ req, err := httpx.NewRequestBuilder("https://api.example.com").
Build()
// Bearer Token Authentication
-req, err := httpx.NewRequestBuilder("https://api.example.com").
+req, err = httpx.NewRequestBuilder("https://api.example.com").
WithMethodGET().
WithPath("/protected/resource").
WithBearerAuth("your-jwt-token").
Build()
-
-// With Generic Client
-client := httpx.NewGenericClient[Resource](
- httpx. httpx.)
```
### Context and Timeout
```go
-// Request with timeout
+// Request with a per-request deadline
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := httpx.NewRequestBuilder("https://api.example.com").
WithMethodGET().
WithPath("/slow-endpoint").
- Context(ctx).
+ WithContext(ctx).
Build()
-// Request with cancellation
-ctx, cancel := context.WithCancel(context.Background())
-
+// Request with manual cancellation
+ctx, cancel = context.WithCancel(context.Background())
go func() {
time.Sleep(2 * time.Second)
- cancel() // Cancel after 2 seconds
+ cancel() // cancel after 2 seconds
}()
-req, err := httpx.NewRequestBuilder("https://api.example.com").
+req, err = httpx.NewRequestBuilder("https://api.example.com").
WithMethodGET().
WithPath("/endpoint").
- Context(ctx).
+ WithContext(ctx).
Build()
```
@@ -1176,13 +1037,14 @@ req, err := httpx.NewRequestBuilder("https://api.example.com").
WithHeader("Accept", "application/json").
WithHeader("Accept-Language", "en-US").
WithHeader("X-Request-ID", generateRequestID()).
- WithHeader("X-Correlation-ID", getCorrelationID()).
WithUserAgent("MyApp/1.0 (Go)").
Build()
```
## API Reference
+Full, always-up-to-date reference: [pkg.go.dev/github.com/slashdevops/httpx](https://pkg.go.dev/github.com/slashdevops/httpx)
+
### RequestBuilder
#### Constructor
@@ -1200,44 +1062,44 @@ req, err := httpx.NewRequestBuilder("https://api.example.com").
- `WithMethodOPTIONS() *RequestBuilder`
- `WithMethodTRACE() *RequestBuilder`
- `WithMethodCONNECT() *RequestBuilder`
-- `WithMethod(method string) *RequestBuilder` - Custom HTTP method with validation
+- `WithMethod(method string) *RequestBuilder` β custom HTTP method with validation
#### URL and Parameters
-- `WithPath(path string) *RequestBuilder` - Set URL path
-- `WithQueryParam(key, value string) *RequestBuilder` - Add single query parameter
-- `QueryParams(params map[string]string) *RequestBuilder` - Add multiple query parameters
+- `WithPath(path string) *RequestBuilder` β set the URL path
+- `WithQueryParam(key, value string) *RequestBuilder` β add a single query parameter
+- `WithQueryParams(params map[string]string) *RequestBuilder` β add multiple query parameters
#### Headers
-- `WithHeader(key, value string) *RequestBuilder` - Set single header
-- `Headers(headers map[string]string) *RequestBuilder` - Set multiple headers
-- `WithContentType(contentType string) *RequestBuilder` - Set Content-Type header
-- `WithAccept(accept string) *RequestBuilder` - Set Accept header
-- `WithUserAgent(userAgent string) *RequestBuilder` - Set User-Agent header
+- `WithHeader(key, value string) *RequestBuilder` β set a single header
+- `WithHeaders(headers map[string]string) *RequestBuilder` β set multiple headers
+- `WithContentType(contentType string) *RequestBuilder` β set the `Content-Type` header
+- `WithAccept(accept string) *RequestBuilder` β set the `Accept` header
+- `WithUserAgent(userAgent string) *RequestBuilder` β set the `User-Agent` header (validated)
#### Authentication
-- `WithBasicAuth(username, password string) *RequestBuilder` - Set Basic authentication
-- `WithBearerAuth(token string) *RequestBuilder` - Set Bearer token authentication
+- `WithBasicAuth(username, password string) *RequestBuilder` β set Basic authentication
+- `WithBearerAuth(token string) *RequestBuilder` β set Bearer token authentication
#### Body
-- `WithJSONBody(body any) *RequestBuilder` - Set JSON body (auto-marshals)
-- `RawBody(body io.Reader) *RequestBuilder` - Set raw body
-- `WithStringBody(body string) *RequestBuilder` - Set string body
-- `BytesBody(body []byte) *RequestBuilder` - Set bytes body
+- `WithJSONBody(body any) *RequestBuilder` β set a JSON body (auto-marshals, sets `Content-Type`, enables retry replay)
+- `WithRawBody(body io.Reader) *RequestBuilder` β set a raw `io.Reader` body
+- `WithStringBody(body string) *RequestBuilder` β set a string body
+- `WithBytesBody(body []byte) *RequestBuilder` β set a `[]byte` body
#### Other
-- `Context(ctx context.Context) *RequestBuilder` - Set request context
-- `Build() (*http.Request, error)` - Build and validate request
+- `WithContext(ctx context.Context) *RequestBuilder` β set the request context
+- `Build() (*http.Request, error)` β build and validate the request
#### Error Handling
-- `HasErrors() bool` - Check if there are validation errors
-- `GetErrors() []error` - Get all validation errors
-- `Reset() *RequestBuilder` - Reset builder state
+- `HasErrors() bool` β whether any validation errors were accumulated
+- `GetErrors() []error` β all accumulated validation errors
+- `Reset() *RequestBuilder` β reset the builder to a clean state
### GenericClient[T any]
@@ -1247,32 +1109,32 @@ req, err := httpx.NewRequestBuilder("https://api.example.com").
#### Options
-- `WithHTTPClient[T any](httpClient HTTPClient) GenericClientOption[T]` - Use a pre-configured HTTP client (takes precedence)
-- `WithTimeout[T any](timeout time.Duration) GenericClientOption[T]` - Set request timeout
-- `WithMaxRetries[T any](maxRetries int) GenericClientOption[T]` - Set maximum retry attempts
-- `WithRetryStrategy[T any](strategy Strategy) GenericClientOption[T]` - Set retry strategy (fixed, jitter, exponential)
-- `WithRetryStrategyAsString[T any](strategy string) GenericClientOption[T]` - Set retry strategy from string
-- `WithRetryBaseDelay[T any](baseDelay time.Duration) GenericClientOption[T]` - Set base delay for retry strategies
-- `WithRetryMaxDelay[T any](maxDelay time.Duration) GenericClientOption[T]` - Set maximum delay for retry strategies
-- `WithMaxIdleConns[T any](maxIdleConns int) GenericClientOption[T]` - Set maximum idle connections
-- `WithIdleConnTimeout[T any](idleConnTimeout time.Duration) GenericClientOption[T]` - Set idle connection timeout
-- `WithTLSHandshakeTimeout[T any](tlsHandshakeTimeout time.Duration) GenericClientOption[T]` - Set TLS handshake timeout
-- `WithExpectContinueTimeout[T any](expectContinueTimeout time.Duration) GenericClientOption[T]` - Set expect continue timeout
-- `WithMaxIdleConnsPerHost[T any](maxIdleConnsPerHost int) GenericClientOption[T]` - Set maximum idle connections per host
-- `WithDisableKeepAlive[T any](disableKeepAlive bool) GenericClientOption[T]` - Disable HTTP keep-alive
+- `WithHTTPClient[T any](httpClient HTTPClient) GenericClientOption[T]` β use a pre-configured client (takes precedence over all other options)
+- `WithTimeout[T any](timeout time.Duration) GenericClientOption[T]`
+- `WithMaxRetries[T any](maxRetries int) GenericClientOption[T]`
+- `WithRetryStrategy[T any](strategy Strategy) GenericClientOption[T]`
+- `WithRetryStrategyAsString[T any](strategy string) GenericClientOption[T]`
+- `WithRetryBaseDelay[T any](baseDelay time.Duration) GenericClientOption[T]`
+- `WithRetryMaxDelay[T any](maxDelay time.Duration) GenericClientOption[T]`
+- `WithMaxIdleConns[T any](maxIdleConns int) GenericClientOption[T]`
+- `WithIdleConnTimeout[T any](idleConnTimeout time.Duration) GenericClientOption[T]`
+- `WithTLSHandshakeTimeout[T any](tlsHandshakeTimeout time.Duration) GenericClientOption[T]`
+- `WithExpectContinueTimeout[T any](expectContinueTimeout time.Duration) GenericClientOption[T]`
+- `WithMaxIdleConnsPerHost[T any](maxIdleConnsPerHost int) GenericClientOption[T]`
+- `WithDisableKeepAlive[T any](disableKeepAlive bool) GenericClientOption[T]`
+- `WithProxy[T any](proxyURL string) GenericClientOption[T]`
+- `WithLogger[T any](logger *slog.Logger) GenericClientOption[T]`
#### Methods
-- `Execute(req *http.Request) (*Response[T], error)` - Execute request with type safety
-- `ExecuteRaw(req *http.Request) (*http.Response, error)` - Execute and return raw response
-- `Do(req *http.Request) (*Response[T], error)` - Alias for Execute
-- `Get(url string) (*Response[T], error)` - Execute GET request
-- `Post(url string, body io.Reader) (*Response[T], error)` - Execute POST request
-- `Put(url string, body io.Reader) (*Response[T], error)` - Execute PUT request
-- `Delete(url string) (*Response[T], error)` - Execute DELETE request
-- `Patch(url string, body io.Reader) (*Response[T], error)` - Execute PATCH request
-- `GetBaseURL() string` - Get configured base URL
-- `GetDefaultHeaders() map[string]string` - Get configured headers
+- `Execute(req *http.Request) (*Response[T], error)` β execute a request with type safety
+- `ExecuteRaw(req *http.Request) (*http.Response, error)` β execute and return the raw response
+- `Do(req *http.Request) (*Response[T], error)` β alias for `Execute`
+- `Get(url string) (*Response[T], error)`
+- `Post(url string, body io.Reader) (*Response[T], error)`
+- `Put(url string, body io.Reader) (*Response[T], error)`
+- `Delete(url string) (*Response[T], error)`
+- `Patch(url string, body io.Reader) (*Response[T], error)`
### ClientBuilder
@@ -1285,6 +1147,7 @@ req, err := httpx.NewRequestBuilder("https://api.example.com").
- `WithTimeout(timeout time.Duration) *ClientBuilder`
- `WithMaxRetries(maxRetries int) *ClientBuilder`
- `WithRetryStrategy(strategy Strategy) *ClientBuilder`
+- `WithRetryStrategyAsString(strategy string) *ClientBuilder`
- `WithRetryBaseDelay(baseDelay time.Duration) *ClientBuilder`
- `WithRetryMaxDelay(maxDelay time.Duration) *ClientBuilder`
- `WithMaxIdleConns(maxIdleConns int) *ClientBuilder`
@@ -1293,9 +1156,20 @@ req, err := httpx.NewRequestBuilder("https://api.example.com").
- `WithTLSHandshakeTimeout(tlsHandshakeTimeout time.Duration) *ClientBuilder`
- `WithExpectContinueTimeout(expectContinueTimeout time.Duration) *ClientBuilder`
- `WithDisableKeepAlive(disableKeepAlive bool) *ClientBuilder`
-- `Build() *http.Client` - Build configured client
+- `WithProxy(proxyURL string) *ClientBuilder`
+- `WithLogger(logger *slog.Logger) *ClientBuilder`
+- `Build() *http.Client` β build the configured client
+
+### Direct Retry Client
-### Retry Strategies
+- `NewHTTPRetryClient(options ...RetryClientOption) *http.Client`
+- `WithMaxRetriesRetry(maxRetries int) RetryClientOption`
+- `WithRetryStrategyRetry(strategy RetryStrategy) RetryClientOption`
+- `WithBaseTransport(transport http.RoundTripper) RetryClientOption`
+- `WithProxyRetry(proxyURL string) RetryClientOption`
+- `WithLoggerRetry(logger *slog.Logger) RetryClientOption`
+
+### Retry Strategy Functions
- `ExponentialBackoff(base, maxDelay time.Duration) RetryStrategy`
- `FixedDelay(delay time.Duration) RetryStrategy`
@@ -1308,9 +1182,9 @@ req, err := httpx.NewRequestBuilder("https://api.example.com").
```go
type Response[T any] struct {
Data T // Parsed response data
- StatusCode int // HTTP status code
Headers http.Header // Response headers
RawBody []byte // Raw response body
+ StatusCode int // HTTP status code
}
```
@@ -1319,9 +1193,9 @@ type Response[T any] struct {
```go
type ErrorResponse struct {
Message string `json:"message,omitempty"`
- StatusCode int `json:"statusCode,omitempty"`
ErrorMsg string `json:"error,omitempty"`
Details string `json:"details,omitempty"`
+ StatusCode int `json:"statusCode,omitempty"`
}
```
@@ -1335,9 +1209,18 @@ const (
)
```
+#### HTTPClient
+
+```go
+// Any type with this method can be injected via WithHTTPClient.
+type HTTPClient interface {
+ Do(req *http.Request) (*http.Response, error)
+}
+```
+
## Best Practices
-### 1. Always Check for Errors
+### 1. Always Check Build Errors
```go
req, err := httpx.NewRequestBuilder(baseURL).
@@ -1354,19 +1237,15 @@ if err != nil {
### 2. Use Type-Safe Clients for JSON APIs
```go
-// Define your model
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
-// Create typed client
-client := httpx.NewGenericClient[User](
- httpx.)
+client := httpx.NewGenericClient[User]()
-// Enjoy type safety
-response, err := client.Get("/users/1")
-// response.Data is User, not interface{}
+response, err := client.Get("https://api.example.com/users/1")
+// response.Data is a User, not interface{}
```
### 3. Configure Retry Logic for Production
@@ -1383,19 +1262,15 @@ client := httpx.NewClientBuilder().
### 4. Reuse HTTP Clients
+Clients are safe for concurrent use and pool connections β create once, share widely:
+
```go
-// Create once, reuse many times
retryClient := httpx.NewClientBuilder().
WithMaxRetries(3).
Build()
-userClient := httpx.NewGenericClient[User](
- httpx.WithHTTPClient[User](retryClient),
-)
-
-postClient := httpx.NewGenericClient[Post](
- httpx.WithHTTPClient[Post](retryClient),
-)
+userClient := httpx.NewGenericClient[User](httpx.WithHTTPClient[User](retryClient))
+postClient := httpx.NewGenericClient[Post](httpx.WithHTTPClient[Post](retryClient))
```
### 5. Use Context for Timeouts
@@ -1407,22 +1282,20 @@ defer cancel()
req, err := httpx.NewRequestBuilder(baseURL).
WithMethodGET().
WithPath("/endpoint").
- Context(ctx).
+ WithContext(ctx).
Build()
```
-### 6. Validate Before Building
+### 6. Validate User-Supplied Input Before Building
```go
builder := httpx.NewRequestBuilder(baseURL).
WithMethodGET().
WithPath("/endpoint")
-// Add potentially invalid inputs
builder.WithHeader(userProvidedKey, userProvidedValue)
builder.WithQueryParam(userProvidedParam, userProvidedValue)
-// Check for errors before building
if builder.HasErrors() {
for _, err := range builder.GetErrors() {
log.Printf("Validation error: %v", err)
@@ -1433,10 +1306,10 @@ if builder.HasErrors() {
req, err := builder.Build()
```
-### 7. Handle API Errors Properly
+### 7. Handle API Errors by Status Code
```go
-response, err := client.Get("/resource")
+response, err := client.Get("https://api.example.com/resource")
if err != nil {
if apiErr, ok := err.(*httpx.ErrorResponse); ok {
switch apiErr.StatusCode {
@@ -1452,26 +1325,26 @@ if err != nil {
} else {
log.Printf("Network error: %v", err)
}
-
return
}
```
## Thread Safety
-All utilities in this package are safe for concurrent use:
+- β
**`GenericClient[T]`** β safe for concurrent use across goroutines.
+- β
**`*http.Client`** built by `ClientBuilder` / `NewHTTPRetryClient` β safe for concurrent use.
+- β
**Retry logic** β preserves request immutability; bodies are replayed via `GetBody`.
+- β οΈ **`RequestBuilder`** β **not** safe for concurrent use. Use one per goroutine.
```go
-client := httpx.NewGenericClient[User](
- httpx.)
+client := httpx.NewGenericClient[User]()
-// Safe to use from multiple goroutines
var wg sync.WaitGroup
for i := 1; i <= 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
- user, err := client.Get(fmt.Sprintf("/users/%d", id))
+ user, err := client.Get(fmt.Sprintf("https://api.example.com/users/%d", id))
if err != nil {
log.Printf("Error fetching user %d: %v", id, err)
return
@@ -1479,29 +1352,44 @@ for i := 1; i <= 10; i++ {
log.Printf("Fetched user: %s", user.Data.Name)
}(i)
}
-
wg.Wait()
```
## Testing
-The package has comprehensive test coverage (88%+):
+The package ships with a comprehensive test suite (89%+ statement coverage):
```bash
-go test ./... -v
-go test ./... -cover
+go test ./... # run all tests
+go test ./... -cover # with coverage
+go test ./... -race # with the race detector
+```
+
+Because the generic client accepts any `HTTPClient`, you can inject a mock to test your
+own code without hitting the network:
+
+```go
+type mockClient struct{}
+
+func (m *mockClient) Do(req *http.Request) (*http.Response, error) {
+ body := io.NopCloser(strings.NewReader(`{"id":1,"name":"Ada"}`))
+ return &http.Response{StatusCode: 200, Body: body, Header: http.Header{}}, nil
+}
+
+client := httpx.NewGenericClient[User](httpx.WithHTTPClient[User](&mockClient{}))
+resp, _ := client.Get("https://api.example.com/users/1")
+// resp.Data.Name == "Ada"
```
## Contributing
-Contributions are welcome! Please ensure:
+Contributions are welcome! Before opening a pull request, please ensure:
-1. Build passes: `go build ./...`
+1. The build passes: `go build ./...`
2. All tests pass: `go test ./...`
3. Code is formatted: `go fmt ./...`
4. Linters pass: `golangci-lint run ./...`
-5. Add tests for new features
-6. Update documentation
+5. New features include tests and documentation updates.
## License
@@ -1509,4 +1397,4 @@ Apache License 2.0. See [LICENSE](LICENSE) for details.
## Credits
-Developed by the slashdevops team using Agentic Development. Inspired by popular HTTP client libraries and Go best practices.
+Developed by the slashdevops team. Inspired by popular HTTP client libraries and Go best practices.
diff --git a/docs.go b/docs.go
index 13f9269..0884dcd 100644
--- a/docs.go
+++ b/docs.go
@@ -22,8 +22,8 @@
//
// req, err := httpx.NewRequestBuilder("https://api.example.com").
// WithMethodGET().
-// Path("/users/123").
-// Header("Accept", "application/json").
+// WithPath("/users/123").
+// WithHeader("Accept", "application/json").
// Build()
//
// Use type-safe generic client:
@@ -33,9 +33,8 @@
// Name string `json:"name"`
// }
//
-// client := httpx.NewGenericClient[User](
-// httpx.// )
-// response, err := client.Get("/users/123")
+// client := httpx.NewGenericClient[User]()
+// response, err := client.Get("https://api.example.com/users/123")
// fmt.Printf("User: %s\n", response.Data.Name)
//
// # Request Builder
@@ -48,11 +47,11 @@
//
// req, err := httpx.NewRequestBuilder("https://api.example.com").
// WithMethodPOST().
-// Path("/users").
-// QueryParam("notify", "true").
-// Header("Content-Type", "application/json").
-// BearerAuth("your-token-here").
-// JSONBody(user).
+// WithPath("/users").
+// WithQueryParam("notify", "true").
+// WithHeader("Content-Type", "application/json").
+// WithBearerAuth("your-token-here").
+// WithJSONBody(user).
// Build()
//
// Request builder features:
@@ -73,8 +72,8 @@
// detect multiple issues at once:
//
// builder := httpx.NewRequestBuilder("https://api.example.com")
-// builder.HTTPMethod("") // Error: empty method
-// builder.WithHeader("", "value") // Error: empty header key
+// builder.WithMethod("") // Error: empty method
+// builder.WithHeader("", "value") // Error: empty header key
// builder.WithQueryParam("key=", "val") // Error: invalid character in key
//
// // Check accumulated errors
@@ -90,9 +89,9 @@
// Builder reuse:
//
// builder := httpx.NewRequestBuilder("https://api.example.com")
-// req1, _ := builder.WithWithMethodGET().WithPath("/users").Build()
+// req1, _ := builder.WithMethodGET().WithPath("/users").Build()
// builder.Reset() // Clear state
-// req2, _ := builder.WithWithMethodPOST().WithPath("/posts").Build()
+// req2, _ := builder.WithMethodPOST().WithPath("/posts").Build()
//
// # Generic HTTP Client
//
@@ -115,7 +114,7 @@
// )
//
// // GET request - response.Data is strongly typed as User
-// response, err := client.Get("/users/1")
+// response, err := client.Get("https://api.example.com/users/1")
// if err != nil {
// log.Fatal(err)
// }
@@ -154,10 +153,10 @@
//
// req, err := httpx.NewRequestBuilder("https://api.example.com").
// WithMethodPOST().
-// Path("/users").
-// ContentType("application/json").
-// Header("X-Request-ID", "unique-123").
-// JSONBody(newUser).
+// WithPath("/users").
+// WithContentType("application/json").
+// WithHeader("X-Request-ID", "unique-123").
+// WithJSONBody(newUser).
// Build()
//
// response, err := client.Execute(req) // Type-safe execution
@@ -167,12 +166,12 @@
// userClient := httpx.NewGenericClient[User](...)
// postClient := httpx.NewGenericClient[Post](...)
//
-// user, _ := userClient.Get("/users/1")
-// posts, _ := postClient.Get(fmt.Sprintf("/users/%d/posts", user.Data.ID))
+// user, _ := userClient.Get("https://api.example.com/users/1")
+// posts, _ := postClient.Get(fmt.Sprintf("https://api.example.com/users/%d/posts", user.Data.ID))
//
// Error handling:
//
-// response, err := client.Get("/users/999")
+// response, err := client.Get("https://api.example.com/users/999")
// if err != nil {
// if apiErr, ok := err.(*httpx.ErrorResponse); ok {
// // Structured API error
@@ -272,7 +271,7 @@
//
// client := httpx.NewGenericClient[User](
// httpx.WithHTTPClient[User](retryClient),
-// httpx.// )
+// )
//
// Default values (validated and adjusted if out of range):
// - Timeout: 5 seconds (valid: 1s-600s)
@@ -299,7 +298,7 @@
// Error handling examples:
//
// // Generic client errors
-// response, err := client.Get("/users/999")
+// response, err := client.Get("https://api.example.com/users/999")
// if err != nil {
// if apiErr, ok := err.(*httpx.ErrorResponse); ok {
// switch apiErr.StatusCode {
@@ -331,8 +330,8 @@
// 1. Use type-safe clients for JSON APIs:
//
// client := httpx.NewGenericClient[User](...)
-// response, err := client.Get("/users/1")
-// // response.Data is User, not interface{}
+// response, err := client.Get("https://api.example.com/users/1")
+// // response.Data is User, not any
//
// 2. Configure retry logic for production:
//
@@ -440,7 +439,7 @@
// wg.Add(1)
// go func(id int) {
// defer wg.Done()
-// user, err := client.Get(fmt.Sprintf("/users/%d", id))
+// user, err := client.Get(fmt.Sprintf("https://api.example.com/users/%d", id))
// // Process user
// }(i)
// }