A convenient HTTP client library for Go with interceptor/middleware support, JSON serialization, multipart uploads, and functional configuration options.
- Interceptor/Middleware Support: Chain request/response handlers for logging, retries, authentication, etc.
- Built-in Retry Interceptor: Automatic retry for transient errors with exponential backoff and jitter
- Functional Options Pattern: Clean configuration with
With*functions - JSON Serialization: Automatic JSON encoding/decoding for request/response bodies
- Multipart File Upload: Support for multipart/form-data file uploads
- Error Handling: Custom error types for HTTP errors with response details
- Base URL Support: Set a base URL for all relative requests
- Header Management: Default headers for all requests
- Test Utilities: Built-in interceptors for debugging and response processing
go get github.com/germangorelkin/http-clientpackage main
import (
"fmt"
"github.com/germangorelkin/http-client"
)
func main() {
// Create a client with default configuration
client := http_client.NewClient(nil)
// GET request with JSON decoding
user := struct {
Name string `json:"name"`
Age int `json:"age"`
}{}
err := client.Get("https://api.example.com/users/1", &user)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("User: %s, Age: %d\n", user.Name, user.Age)
}// POST request with JSON encoding
newUser := struct {
Name string `json:"name"`
Age int `json:"age"`
}{
Name: "John",
Age: 30,
}
createdUser := struct {
ID string `json:"id"`
Name string `json:"name"`
Age int `json:"age"`
}{}
err := client.Post("https://api.example.com/users", newUser, &createdUser)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("Created user ID: %s\n", createdUser.ID)// Create a multipart form
form := http_client.NewMultipartForm()
form.AddField("description", "Profile picture")
form.AddField("user_id", "123")
// Add file from io.Reader (e.g., from os.Open, bytes.NewReader, etc.)
file, err := os.Open("profile.jpg")
if err != nil {
log.Fatal(err)
}
defer file.Close()
form.AddFile("attachment", "profile.jpg", file)
// Upload with multipart POST
var resp struct {
Success bool `json:"success"`
URL string `json:"url"`
}
err = client.PostMultipart("https://api.example.com/upload", form, &resp)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("Upload successful: %s\n", resp.URL)form := http_client.NewMultipartForm()
form.AddField("album", "vacation")
// Add multiple files under same field name
form.AddFile("photos", "photo1.jpg", file1)
form.AddFile("photos", "photo2.jpg", file2)
form.AddFile("photos", "photo3.jpg", file3)
err := client.PostMultipart("https://api.example.com/upload", form, nil)client, err := http_client.New(nil,
http_client.WithBaseURL("https://api.example.com/v1"),
http_client.WithUserAgent("my-app/1.0"),
http_client.WithAuthorization("Bearer token123"),
http_client.WithInterceptor(http_client.DefaultRetryInterceptor()),
http_client.WithInterceptor(http_client.DumpInterceptor),
)
if err != nil {
log.Fatal(err)
}| Option | Description | Example |
|---|---|---|
WithBaseURL |
Sets base URL for relative paths | WithBaseURL("https://api.example.com") |
WithUserAgent |
Sets User-Agent header | WithUserAgent("my-app/1.0") |
WithAuthorization |
Sets Authorization header | WithAuthorization("Bearer token") |
WithInterceptor |
Adds an interceptor to the chain | WithInterceptor(myInterceptor) |
| Method | Description | Example |
|---|---|---|
NewMultipartForm() |
Creates empty multipart form | form := NewMultipartForm() |
AddField(name, value) |
Adds text field (can be called multiple times for same name) | form.AddField("description", "my file") |
AddFile(fieldName, fileName, reader) |
Adds file from io.Reader |
form.AddFile("file", "photo.jpg", reader) |
| Method | Description | Example |
|---|---|---|
NewMultipartRequest(method, url, form) |
Creates multipart HTTP request | req, err := client.NewMultipartRequest("POST", "/upload", form) |
PostMultipart(url, form, out) |
Sends multipart POST and decodes response | err := client.PostMultipart("/upload", form, &resp) |
| Option | Description | Default |
|---|---|---|
WithMaxRetries |
Maximum retry attempts | 3 |
WithBackoff |
Base and maximum delay for exponential backoff | 100ms base, 1s max |
WithRetryOnStatus |
HTTP status codes that trigger retry | [408, 429, 500, 502, 503, 504] |
WithRetryMethods |
HTTP methods safe to retry | ["GET", "HEAD", "PUT", "DELETE", "OPTIONS"] |
WithRetryOnError |
Custom function to determine retryable errors | Network errors and context deadline |
client := http_client.NewClient(nil)
// Set headers
client.SetHeader("X-Custom-Header", "value")
client.SetAuthorization("Bearer new-token")
// Add interceptors
client.AddInterceptor(myInterceptor)
// Create multipart form
form := http_client.NewMultipartForm()
form.AddField("key", "value")
// ... add files// For more control, create request manually
form := http_client.NewMultipartForm()
form.AddField("description", "document")
form.AddFile("file", "document.pdf", fileReader)
req, err := client.NewMultipartRequest("POST", "/upload", form)
if err != nil {
log.Fatal(err)
}
// Add custom headers to multipart request
req.Header.Set("X-Custom-Header", "value")
// Execute with context
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := client.Do(ctx, req, &response)Interceptors are middleware functions that can intercept and modify HTTP requests and responses. They follow the chain of responsibility pattern.
Logs complete request and response dumps for debugging:
client, err := http_client.New(nil,
http_client.WithInterceptor(http_client.DumpInterceptor),
)Replaces NaN with null in JSON responses to fix invalid JSON:
client, err := http_client.New(nil,
http_client.WithInterceptor(http_client.ResponseInterceptor),
)Automatically retries requests on transient errors with exponential backoff:
// Default retry interceptor (3 retries, exponential backoff)
client, err := http_client.New(nil,
http_client.WithInterceptor(http_client.DefaultRetryInterceptor()),
)
// Custom retry configuration
retryInterceptor := http_client.NewRetryInterceptor(
http_client.WithMaxRetries(5),
http_client.WithBackoff(100*time.Millisecond, 2*time.Second),
http_client.WithRetryOnStatus([]int{408, 429, 500, 502, 503, 504}),
http_client.WithRetryMethods([]string{"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}),
)
client, err := http_client.New(nil,
http_client.WithInterceptor(retryInterceptor),
)Default Retry Behavior:
- Max retries: 3 attempts
- Backoff: Exponential with jitter (100ms, 200ms, 400ms)
- Retry on status codes: 408, 429, 500, 502, 503, 504
- Retry on errors: Network errors and context deadline exceeded
- Safe methods: GET, HEAD, PUT, DELETE, OPTIONS (POST, PATCH are not retried)
func LoggingInterceptor(req *http.Request, handler http_client.Handler) (*http.Response, error) {
// Log request
log.Printf("Request: %s %s", req.Method, req.URL)
// Call next handler
resp, err := handler(req)
// Log response
if err == nil {
log.Printf("Response: %d %s", resp.StatusCode, resp.Status)
}
return resp, err
}
func AuthInterceptor(token string) http_client.Interceptor {
return func(req *http.Request, handler http_client.Handler) (*http.Response, error) {
req.Header.Set("Authorization", "Bearer "+token)
return handler(req)
}
}
// Usage
client, err := http_client.New(nil,
http_client.WithInterceptor(LoggingInterceptor),
http_client.WithInterceptor(AuthInterceptor("my-token")),
)// Example of a custom metrics interceptor
func MetricsInterceptor(metricsClient *MetricsClient) http_client.Interceptor {
return func(req *http.Request, handler http_client.Handler) (*http.Response, error) {
start := time.Now()
resp, err := handler(req)
duration := time.Since(start)
statusCode := 0
if resp != nil {
statusCode = resp.StatusCode
}
metricsClient.RecordRequest(req.Method, req.URL.Path, statusCode, duration, err)
return resp, err
}
}
// Usage with built-in and custom interceptors
client, err := http_client.New(nil,
http_client.WithInterceptor(http_client.DefaultRetryInterceptor()),
http_client.WithInterceptor(MetricsInterceptor(metrics)),
http_client.WithInterceptor(http_client.DumpInterceptor),
)Non-2xx status codes return an ErrorResponse:
err := client.Get("https://api.example.com/not-found", nil)
if err != nil {
if errRes, ok := err.(*http_client.ErrorResponse); ok {
fmt.Printf("HTTP Error: %d\n", errRes.Response.StatusCode)
fmt.Printf("Message: %s\n", errRes.Message)
fmt.Printf("Request: %s %s\n",
errRes.Response.Request.Method,
errRes.Response.Request.URL)
}
}type ErrorResponse struct {
Response *http.Response // Original HTTP response
Message string // Response body as string
RequestID string // Optional request ID for tracing
}customClient := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
},
}
client := http_client.NewClient(customClient)ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := client.NewRequest("GET", "/users/1", nil)
if err != nil {
log.Fatal(err)
}
resp, err := client.Do(ctx, req, &user)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
log.Println("Request timed out")
}
}req, err := client.NewRequest("GET", "/users/1", nil)
if err != nil {
log.Fatal(err)
}
resp, err := client.Do(context.Background(), req, nil)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
// Access raw response
fmt.Printf("Status: %s\n", resp.Status)
fmt.Printf("Headers: %v\n", resp.Header)func TestClientIntegration(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `{"id": "123", "name": "Test User"}`)
}))
defer ts.Close()
client := http_client.NewClient(nil)
user := struct {
ID string `json:"id"`
Name string `json:"name"`
}{}
err := client.Get(ts.URL, &user)
assert.NoError(t, err)
assert.Equal(t, "123", user.ID)
assert.Equal(t, "Test User", user.Name)
}func TestInterceptor(t *testing.T) {
var requestLogged bool
var responseLogged bool
loggingInterceptor := func(req *http.Request, handler http_client.Handler) (*http.Response, error) {
requestLogged = true
resp, err := handler(req)
if err == nil {
responseLogged = true
}
return resp, err
}
client, err := http_client.New(nil,
http_client.WithInterceptor(loggingInterceptor),
)
assert.NoError(t, err)
// Test with httptest server...
assert.True(t, requestLogged)
assert.True(t, responseLogged)
}// Good: Create once, reuse
var apiClient *http_client.Client
func init() {
var err error
apiClient, err = http_client.New(nil,
http_client.WithBaseURL("https://api.example.com"),
http_client.WithUserAgent("my-service"),
)
if err != nil {
log.Fatal(err)
}
}req, err := client.NewRequest("GET", "/data", nil)
if err != nil {
return err
}
resp, err := client.Do(ctx, req, &data)
if err != nil {
return err
}
// Response body is automatically closed by client.Do()// For small files (<10MB), use standard multipart upload:
form := http_client.NewMultipartForm()
form.AddField("description", "small file")
form.AddFile("file", "document.pdf", fileReader)
err := client.PostMultipart("/upload", form, &resp)
// For large files, consider:
// 1. Chunking the file into smaller parts
// 2. Using streaming uploads (future feature)
// 3. Compressing before upload
// 4. Using direct file upload APIs if available
// Current implementation buffers entire form in memory.
// Monitor memory usage when uploading multiple large files.
// Future streaming API example (planned):
// ```go
// // Streaming upload for large files without buffering
// err := client.PostMultipartStream("/upload", func(w *multipart.Writer) error {
// // Add fields
// if err := w.WriteField("description", "large file"); err != nil {
// return err
// }
//
// // Stream file directly
// part, err := w.CreateFormFile("file", "large-video.mp4")
// if err != nil {
// return err
// }
//
// // Stream from file without buffering entire content
// file, err := os.Open("large-video.mp4")
// if err != nil {
// return err
// }
// defer file.Close()
//
// _, err = io.Copy(part, file)
// return err
// }, &resp)
// ```func GetUser(id string) (*User, error) {
if id == "" {
return nil, errors.New("user ID cannot be empty")
}
user := &User{}
err := client.Get(fmt.Sprintf("/users/%s", id), user)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
return user, nil
}Get(url string, out any) error- Simple GET requestPost(url string, in, out any) error- Simple POST requestDoRequestWithClient(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error)- Low-level request executionCheckResponse(r *http.Response) error- Check HTTP response statusDefaultRetryInterceptor() Interceptor- Retry interceptor with default configurationNewRetryInterceptor(opts ...RetryOpt) Interceptor- Configurable retry interceptor
NewClient(httpClient *http.Client) *Client- Create client with defaultsNew(httpClient *http.Client, opts ...ClientOpt) (*Client, error)- Create configured client(*Client) Get(url string, out any) error- GET with JSON decode(*Client) Post(url string, in, out any) error- POST with JSON encode/decode(*Client) Do(ctx context.Context, req *http.Request, v any) (*http.Response, error)- Execute request(*Client) NewRequest(method, urlStr string, body any) (*http.Request, error)- Build request(*Client) AddInterceptor(inter Interceptor) error- Add middleware(*Client) SetHeader(key, value string)- Set default header(*Client) SetAuthorization(auth string)- Set auth header
type Client struct- Main HTTP clienttype ClientOpt func(*Client) error- Functional optiontype Handler func(*http.Request) (*http.Response, error)- Request handlertype Interceptor func(*http.Request, Handler) (*http.Response, error)- Middlewaretype ErrorResponse struct- HTTP error responsetype RetryConfig struct- Configuration for retry behaviortype RetryOpt func(*RetryConfig)- Functional option for retry configuration
WithMaxRetries(n int) RetryOpt- Set maximum retry attemptsWithBackoff(base, max time.Duration) RetryOpt- Set exponential backoff delaysWithRetryOnStatus(codes []int) RetryOpt- Set HTTP status codes that trigger retryWithRetryMethods(methods []string) RetryOpt- Set HTTP methods safe to retryWithRetryOnError(fn func(error) bool) RetryOpt- Set custom error retry logic
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Ensure all tests pass:
go test ./... - Submit a pull request
# Run all tests
go test ./... -v
# Run specific test
go test -run TestClient_Get -v
# Run with coverage
go test -cover ./...
# Run with race detector
go test -race ./...- Follow standard Go conventions
- Use
gofmtfor formatting - Add tests for new functionality
- Update documentation for API changes
This project is licensed under the MIT License - see the LICENSE file for details.
- Added Multipart Support: Robust support for multipart/form-data uploads
- Added
PostMultipartmethod for file uploads - Added
MultipartFormbuilder for complex requests - Support for multiple files and fields
- Support for uploading from
io.Reader
- Added
- Added RetryInterceptor: Automatic retry for transient errors with exponential backoff
DefaultRetryInterceptor()with sensible defaultsNewRetryInterceptor(opts ...RetryOpt)for custom configuration- Configurable retry count, backoff, status codes, and methods
- Jitter added to prevent thundering herd
- Safe request cloning for retry attempts
- Added interceptor/middleware support
- Added functional options pattern
- Improved error handling with ErrorResponse
- Added base URL support
- Added header management
- Initial release with basic HTTP client functionality