HTTPEZ is a lightweight HTTP client wrapper for Go that transforms the verbose net/http package into a fluent, chainable API. Think of it as jQuery for Go’s HTTP client—it keeps all the power while dropping the ceremony. In this guide, you’ll learn how to leverage HTTPEZ to write cleaner HTTP client code, implement advanced middleware patterns, and handle edge cases that would make your vanilla net/http implementation cry.
TL;DR: If you’re looking for a Go HTTP client wrapper with a fluent, chainable API for Go HTTP requests, HTTPEZ gives you terse request builders, first-class context support, and transport-level middleware (retry with exponential backoff, circuit breaker pattern, request deduplication, rate limiting, and more).
Why HTTPEZ Beats Raw Net/Http (And Most Other Clients)
Here’s the dirty secret most Go tutorials won’t tell you: the standard net/http client is powerful but painfully verbose. You’ve probably written this code a hundred times:
req, err := http.NewRequest("GET", "https://api.example.com/users", nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer token")
q := req.URL.Query()
q.Add("page", "1")
req.URL.RawQuery = q.Encode()
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
That’s 19 lines of boilerplate for a simple GET request. With HTTPEZ, the same logic becomes:
body, _, err := httpez.NewClient().
Get("https://api.example.com/users").
WithHeader("Authorization", "Bearer token").
WithQuery("page", "1").
AsBytes()
Five lines. No ceremony. No forgetting to close the response body (a classic goroutine leak source).
Why this matters: Less surface area means fewer chances to introduce bugs—especially in production where timeouts, retries, and response handling are easy to get wrong. HTTPEZ’s chainable API for Go HTTP requests is built to express intent, not plumbing.
Step 1: Install and Initialize HTTPEZ
First, grab the package:
go get github.com/etaaa/httpez
Now, create your first client. Configure defaults once; reap the rewards across every call.
package main
import (
"fmt"
"github.com/etaaa/httpez"
)
func main() {
// Create a reusable client with base configuration
client := httpez.NewClient().
WithBaseURL("https://api.github.com").
WithHeader("Accept", "application/vnd.github.v3+json").
WithHeader("User-Agent", "my-awesome-app/1.0")
fmt.Println("Client initialized:", client != nil)
}
Why this pattern wins: The client stores your base URL and default headers, applying them to every request. This eliminates dozens of lines of repetitive configuration code that otherwise litters handlers and services.
Tip: Reuse the client across your app. A single client enables connection pooling, backoff, and shared middleware—ideal for microservices and CLIs.
Step 2: Master the Request Builders
HTTPEZ provides intuitive methods for all HTTP verbs. Each returns a builder you can chain for headers, query params, body, and context.
GET Requests with Query Parameters
// Simple GET with query params
users, _, err := client.
Get("/users").
WithQuery("per_page", "100").
WithQuery("since", "12345").
AsBytes()
Notice the AsBytes() method? It handles the entire response lifecycle—reading the body, closing it, and returning the data. No more leaked file descriptors.
POST Requests with JSON
Here’s where HTTPEZ really shines compared to standard approaches:
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
payload := CreateUserRequest{
Name: "John Doe",
Email: "john@example.com",
}
// HTTPEZ automatically serializes and sets Content-Type
response, _, err := client.
Post("/users").
WithJSON(payload).
AsBytes()
WithJSON() automatically:
- Serializes your struct to JSON
- Sets
Content-Type: application/json - Surfaces marshaling errors properly
Form Data Submissions
For form submissions, HTTPEZ provides an equally elegant solution—great for OAuth flows and legacy APIs:
// Submit form data (perfect for OAuth flows or legacy APIs)
token, _, err := client.
Post("/oauth/token").
WithForm(map[string]string{
"grant_type": "client_credentials",
"client_id": "your-client-id",
"client_secret": "your-client-secret",
}).
AsBytes()
Note: If your provider is picky about Accept or User-Agent, set them once on the client. That’s the “fluent, chainable API” advantage in daily use.
Step 3: Handle Responses Like a Pro
HTTPEZ gives you multiple response-handling modes to match real-world use cases.
Direct JSON Unmarshaling
Stop hand-writing JSON boilerplate:
type User struct {
ID int `json:"id"`
Login string `json:"login"`
}
var users []User
_, err := client.
Get("/users").
AsJSON(&users)
// The response is automatically unmarshaled into your struct
Access Raw Response for Status Codes
Sometimes you need both the body and response metadata:
body, resp, err := client.
Get("/users/octocat").
AsBytes()
if err != nil {
return err
}
if resp.StatusCode == 404 {
fmt.Println("User not found")
} else if resp.StatusCode == 200 {
fmt.Printf("Found user: %s\n", string(body))
}
Stream Large Responses
For large payloads, stream directly to disk (or to a hash) instead of loading into memory:
_, resp, err := client.
Get("/repos/torvalds/linux/tarball/master").
AsResponse()
if err != nil {
return err
}
defer resp.Body.Close()
// Stream directly to a file
file, _ := os.Create("linux-master.tar.gz")
defer file.Close()
io.Copy(file, resp.Body)
Tip: Prefer AsResponse() when you expect big files, video, or long-lived downloads. It’s the easiest way to avoid OOMs and keep memory predictable.
Step 4: Context Support for Timeouts and Cancellation
Production systems live or die by their timeouts. HTTPEZ makes context first-class.
// Set a 5-second timeout for a specific request
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
data, _, err := client.
Get("/slow-endpoint").
WithContext(ctx).
AsBytes()
if errors.Is(err, context.DeadlineExceeded) {
fmt.Println("Request timed out")
}
Cancellation Propagation in Concurrent Requests
Coordinate concurrent requests with proper cancellation. If one fails, cancel the rest cleanly.
func fetchUserData(userID string) (*UserProfile, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var profile UserProfile
var repos []Repository
var gists []Gist
g, ctx := errgroup.WithContext(ctx)
// Fetch user profile
g.Go(func() error {
_, err := client.
Get(fmt.Sprintf("/users/%s", userID)).
WithContext(ctx).
AsJSON(&profile)
return err
})
// Fetch repositories
g.Go(func() error {
_, err := client.
Get(fmt.Sprintf("/users/%s/repos", userID)).
WithContext(ctx).
AsJSON(&repos)
return err
})
// Fetch gists
g.Go(func() error {
_, err := client.
Get(fmt.Sprintf("/users/%s/gists", userID)).
WithContext(ctx).
AsJSON(&gists)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
profile.Repos = repos
profile.Gists = gists
return &profile, nil
}
Why this matters: Proper cancellation prevents goroutine leaks, reduces tail latency, and saves compute. This is the foundation that keeps your Go HTTP client code production-grade.
Step 5: Implement Custom Middleware (The Secret Sauce)
Here’s where we go beyond “simple client.” HTTPEZ supports middleware through custom transports, enabling patterns you usually only see in mature service meshes.
Automatic Retry with Exponential Backoff
type RetryTransport struct {
Base http.RoundTripper
MaxRetries int
}
func (t *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
var resp *http.Response
var err error
backoff := []time.Duration{
1 * time.Second,
3 * time.Second,
10 * time.Second,
}
for i := 0; i <= t.MaxRetries; i++ {
// Clone the request body for retry
var bodyBytes []byte
if req.Body != nil {
bodyBytes, _ = io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
resp, err = t.Base.RoundTrip(req)
// Restore body for next retry
if bodyBytes != nil {
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
// Success or non-retryable error
if err == nil && resp.StatusCode < 500 {
return resp, nil
}
// Don't retry after last attempt
if i < t.MaxRetries {
if i < len(backoff) {
time.Sleep(backoff[i])
} else {
time.Sleep(backoff[len(backoff)-1])
}
}
}
return resp, err
}
// Use it with HTTPEZ
client := httpez.NewClient().
WithTransport(&RetryTransport{
Base: http.DefaultTransport,
MaxRetries: 3,
})
Note: In production, add jitter to avoid thundering herds: sleep backoff[i] + rand(0..backoff[i]/2).
Request/Response Logging Middleware
Gain visibility without littering call sites.
type LoggingTransport struct {
Base http.RoundTripper
Logger *log.Logger
}
func (t *LoggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
startTime := time.Now()
// Log request
t.Logger.Printf("[%s] %s %s",
req.Method,
req.URL.String(),
req.Header.Get("User-Agent"))
resp, err := t.Base.RoundTrip(req)
duration := time.Since(startTime)
if err != nil {
t.Logger.Printf("[ERROR] Request failed after %v: %v", duration, err)
return nil, err
}
// Log response
t.Logger.Printf("[%d] Response received in %v",
resp.StatusCode,
duration)
return resp, nil
}
Tip: Consider redacting secrets before logging—especially Authorization and cookies.
Circuit Breaker Pattern
Stop cascading failures early:
type CircuitBreaker struct {
Base http.RoundTripper
FailureThreshold int
ResetTimeout time.Duration
mu sync.Mutex
failures int
lastFailTime time.Time
state string // "closed", "open", "half-open"
}
func (cb *CircuitBreaker) RoundTrip(req *http.Request) (*http.Response, error) {
cb.mu.Lock()
// Check if circuit should reset
if cb.state == "open" &&
time.Since(cb.lastFailTime) > cb.ResetTimeout {
cb.state = "half-open"
cb.failures = 0
}
// Fast fail if circuit is open
if cb.state == "open" {
cb.mu.Unlock()
return nil, fmt.Errorf("circuit breaker is open")
}
cb.mu.Unlock()
resp, err := cb.Base.RoundTrip(req)
cb.mu.Lock()
defer cb.mu.Unlock()
if err != nil || (resp != nil && resp.StatusCode >= 500) {
cb.failures++
cb.lastFailTime = time.Now()
if cb.failures >= cb.FailureThreshold {
cb.state = "open"
}
return resp, err
}
// Success - reset failures
if cb.state == "half-open" {
cb.state = "closed"
}
cb.failures = 0
return resp, nil
}
Pattern recap (long-tail keyword alert): circuit breaker pattern Go http client + retry with exponential backoff Go are battle-tested resilience tools. HTTPEZ makes them trivial to wire in.
Step 6: Advanced Patterns Most Devs Miss
Connection Pool Optimization
HTTPEZ inherits Go’s pooling via http.Transport. Tune it for throughput:
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
DisableCompression: false, // Enable gzip by default
// Critical for high-throughput scenarios
MaxConnsPerHost: 0, // No limit
}
client := httpez.NewClient().
WithTransport(transport).
WithBaseURL("https://api.example.com")
Tip: Monitor httptrace or exporter metrics to verify you’re actually reusing connections.
Request Deduplication
Prevent duplicate requests in high-concurrency scenarios:
type DedupeTransport struct {
Base http.RoundTripper
inflight map[string]*sync.WaitGroup
results map[string]*result
mu sync.Mutex
}
type result struct {
resp *http.Response
err error
}
func (d *DedupeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
key := fmt.Sprintf("%s:%s", req.Method, req.URL.String())
d.mu.Lock()
if wg, exists := d.inflight[key]; exists {
d.mu.Unlock()
wg.Wait() // Wait for in-flight request
d.mu.Lock()
res := d.results[key]
d.mu.Unlock()
return res.resp, res.err
}
// First request for this key
wg := &sync.WaitGroup{}
wg.Add(1)
if d.inflight == nil {
d.inflight = map[string]*sync.WaitGroup{}
}
if d.results == nil {
d.results = map[string]*result{}
}
d.inflight[key] = wg
d.mu.Unlock()
// Make the actual request
resp, err := d.Base.RoundTrip(req)
// Store result and notify waiters
d.mu.Lock()
d.results[key] = &result{resp: resp, err: err}
delete(d.inflight, key)
d.mu.Unlock()
wg.Done()
// Clean up results after a delay
go func() {
time.Sleep(5 * time.Second)
d.mu.Lock()
delete(d.results, key)
d.mu.Unlock()
}()
return resp, err
}
Use case: Fan-out/fan-in workloads where a burst of identical requests hits the same key (e.g., request deduplication Go for hot cache misses).
Step 7: Testing HTTP Clients (Without Hitting Real APIs)
Mock transports let you test behavior deterministically—no flaky integration tests required.
type MockTransport struct {
Responses map[string]MockResponse
}
type MockResponse struct {
Status int
Body string
Error error
}
func (m *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
key := fmt.Sprintf("%s:%s", req.Method, req.URL.Path)
if mock, exists := m.Responses[key]; exists {
if mock.Error != nil {
return nil, mock.Error
}
return &http.Response{
StatusCode: mock.Status,
Body: io.NopCloser(strings.NewReader(mock.Body)),
Header: make(http.Header),
}, nil
}
return nil, fmt.Errorf("no mock for %s", key)
}
// In your tests
func TestUserFetch(t *testing.T) {
mockTransport := &MockTransport{
Responses: map[string]MockResponse{
"GET:/users/123": {
Status: 200,
Body: `{"id": 123, "name": "Test User"}`,
},
},
}
client := httpez.NewClient().
WithTransport(mockTransport).
WithBaseURL("https://api.example.com")
var user User
_, err := client.Get("/users/123").AsJSON(&user)
assert.NoError(t, err)
assert.Equal(t, 123, user.ID)
assert.Equal(t, "Test User", user.Name)
}
Performance Tips That Actually Matter
1) Reuse Clients, Not Requests
// WRONG - Creates new transport/connection pool each time
func fetchUser(id string) (*User, error) {
client := httpez.NewClient()
// ...
}
// RIGHT - Reuse client across requests
var client = httpez.NewClient().
WithBaseURL("https://api.example.com")
func fetchUser(id string) (*User, error) {
var u User
_, err := client.Get(fmt.Sprintf("/users/%s", id)).AsJSON(&u)
return &u, err
}
Why: Reusing the client preserves the connection pool and avoids repeated TLS handshakes.
2) Set Appropriate Timeouts
// Don't rely on defaults - be explicit
client := httpez.NewClient().
WithTransport(&http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
})
Tip: Pair transport-level timeouts with per-request context deadlines. Defense in depth.
3) Use Request Pooling for High Throughput
// Pool request objects to reduce GC pressure
var requestPool = sync.Pool{
New: func() interface{} {
return &bytes.Buffer{}
},
}
func makeRequest(data []byte) {
buf := requestPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
requestPool.Put(buf)
}()
buf.Write(data)
// Use buf for request body
}
When it helps: Hot paths with many small allocations (e.g., telemetry fanout).
Common Pitfalls and How to Avoid Them
Don’t Forget Context Cancellation
// BAD - Context without cancellation
ctx := context.Background()
// GOOD - Always use cancellable contexts
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
Why: Zombie requests waste sockets and compute. Always cancel.
Handle Partial Reads
// When streaming large responses
_, resp, err := client.Get("/large-file").AsResponse()
if err != nil {
return err
}
defer resp.Body.Close()
// Use io.Copy instead of ReadAll for large files
written, err := io.Copy(destination, resp.Body)
if err != nil {
// Handle partial read
log.Printf("Partial read: %d bytes written before error: %v", written, err)
}
Reality check: Networks fail mid-stream. Log partial progress for observability.
Rate Limiting Without External Libraries
type RateLimitTransport struct {
Base http.RoundTripper
Limiter *rate.Limiter
}
func (t *RateLimitTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if err := t.Limiter.Wait(req.Context()); err != nil {
return nil, err
}
return t.Base.RoundTrip(req)
}
// Use it
client := httpez.NewClient().
WithTransport(&RateLimitTransport{
Base: http.DefaultTransport,
Limiter: rate.NewLimiter(rate.Limit(10), 1), // 10 requests per second
})
SEO-friendly clarity: rate limiting http client Go is easy to implement with a transport. You don’t need a full-blown service mesh.
FAQs and Comparisons
How is HTTPEZ different from raw net/http?
It’s a Go HTTP client wrapper that trades repetitive boilerplate for a fluent, chainable API. You keep http.Transport power (TLS, pooling, proxies) while gaining terse request builders (Get, Post, WithJSON, WithQuery, WithHeader, AsBytes, AsJSON, AsResponse) that make intent obvious.
HTTPEZ vs. other Go HTTP clients?
Many third-party clients abstract too much (hard to reach the transport), or too little (you still wire JSON, headers, and query strings by hand). HTTPEZ strikes a practical middle ground: it exposes the transport for middleware (retry with exponential backoff, circuit breaker pattern, request deduplication), but gives you high-level affordances for everyday work.
Is it production-ready for microservices?
Yes—especially when paired with:
- Per-request contexts and sane deadlines
- Middleware for retries with jitter, circuit breaking, and rate limiting
- Pooled clients and tuned
http.Transport - Observability via logging transport + metrics
Can I use it in CLIs and cron jobs?
Absolutely. The fluent API keeps your CLI code tiny, while transport middleware handles flaky networks and backoff logic for you.
Wrapping Up
HTTPEZ transforms Go’s HTTP client from a verbose necessity into an elegant tool. By leveraging its fluent API, built-in response handling, and transport-level middleware support, you can write HTTP client code that’s both more readable and more robust than traditional approaches with net/http.
We covered the essentials—from GETs and JSON posts to streaming large responses—and then went deeper into real-world patterns: context propagation, retries with exponential backoff, circuit breakers, request deduplication, connection pool optimization, rate limiting, and testing Go HTTP clients without hitting real APIs.
Next time you reach for net/http, ask yourself: do you want to write 20 lines of boilerplate, or 5 lines that actually express your intent?