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) // }