From 77fac57e521f602ae0ffc0bf2a97e6c2ee062ecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sat, 11 Jul 2026 16:05:57 +0200 Subject: [PATCH] docs: fix inaccurate examples and add architecture diagrams The README and package documentation contained code snippets that did not compile or referenced APIs that do not exist. This corrects them against the actual public API and adds Mermaid diagrams to explain the design. README.md: - Fix broken snippets (stray `httpx.)` fragments, `WithWithMethodGET` typos) - Correct method names to the real API: WithMethod (not HTTPMethod), WithContext (not Context), WithQueryParams/WithHeaders/WithRawBody/ WithBytesBody (not QueryParams/Headers/RawBody/BytesBody) - Remove references to methods that do not exist (GetBaseURL, GetDefaultHeaders) and to features the client does not provide ("base URL resolution", "default headers applied to all requests") - Use full URLs in GenericClient.Get examples (relative paths are not supported by the convenience methods) - Document previously-undocumented options: WithProxy, WithLogger, WithRetryStrategyAsString, and the NewHTTPRetryClient family - Add an mocking example leveraging the HTTPClient interface - Add Mermaid diagrams: component overview, request lifecycle sequence, composition model, error-accumulation flow, retry decision flow, and a backoff-strategy comparison chart docs.go (package doc): - Same broken-snippet and method-name fixes as the README - Use full URLs and `any` instead of `interface{}` per project style No source-behavior changes; go build, go vet, gofmt, and go test all pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1098 ++++++++++++++++++++++++----------------------------- docs.go | 53 ++- 2 files changed, 519 insertions(+), 632 deletions(-) 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) // }