Skip to main content

HTTP Client — net/http Package

Go's net/http package lets you build production-grade HTTP clients using only the standard library. We'll go step by step from basic GET requests through custom Transport and retry patterns.

Basic HTTP Requests

http.Get / http.Post

package main

import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
)

func main() {
// GET request — the simplest approach
resp, err := http.Get("https://httpbin.org/get")
if err != nil {
fmt.Println("GET failed:", err)
return
}
defer resp.Body.Close() // must close Body to return connection to pool!

fmt.Println("status code:", resp.StatusCode)
fmt.Println("Content-Type:", resp.Header.Get("Content-Type"))

body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("body read failed:", err)
return
}
fmt.Printf("response body (%d bytes):\n%s\n", len(body), body[:min(len(body), 200)])

// POST JSON request
jsonBody := `{"name":"Alice","age":30}`
resp2, err := http.Post(
"https://httpbin.org/post",
"application/json",
strings.NewReader(jsonBody),
)
if err != nil {
fmt.Println("POST failed:", err)
return
}
defer resp2.Body.Close()
fmt.Println("POST status:", resp2.StatusCode)

// PostForm: send HTML form data
formData := url.Values{
"username": {"alice"},
"password": {"secret"},
}
resp3, err := http.PostForm("https://httpbin.org/post", formData)
if err != nil {
fmt.Println("PostForm failed:", err)
return
}
defer resp3.Body.Close()
fmt.Println("PostForm status:", resp3.StatusCode)
}

func min(a, b int) int {
if a < b {
return a
}
return b
}

http.NewRequest + Client.Do — Fine-Grained Control

package main

import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)

type APIResponse struct {
URL string `json:"url"`
Headers map[string]string `json:"headers"`
JSON map[string]any `json:"json"`
}

func main() {
// NewRequest: freely configure request headers and method
body := strings.NewReader(`{"query":"Go programming"}`)
req, err := http.NewRequest("POST", "https://httpbin.org/post", body)
if err != nil {
fmt.Println("request creation failed:", err)
return
}

// set headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "MyGoApp/1.0")
req.Header.Set("X-Custom-Header", "custom-value")

// set cookie
req.AddCookie(&http.Cookie{
Name: "session",
Value: "abc123",
})

// Basic Auth
req.SetBasicAuth("user", "password")

// Bearer Token
// req.Header.Set("Authorization", "Bearer "+token)

// use DefaultClient (no timeout — not recommended in production)
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println("request failed:", err)
return
}
defer resp.Body.Close()

// check status code
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("error response [%d]: %s\n", resp.StatusCode, body)
return
}

// decode JSON
var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
fmt.Println("JSON decode failed:", err)
return
}
fmt.Println("request URL:", result.URL)
}

Custom http.Client — Production Configuration

The default http.DefaultClient has no timeout, which is dangerous in production. Always use a custom http.Client.

package main

import (
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"time"
)

// newHTTPClient: create an HTTP client suitable for production
func newHTTPClient() *http.Client {
transport := &http.Transport{
// connection pool settings
MaxIdleConns: 100, // max idle connections total
MaxIdleConnsPerHost: 10, // max idle connections per host
MaxConnsPerHost: 50, // max concurrent connections per host
IdleConnTimeout: 90 * time.Second, // idle connection keepalive duration

// timeout settings
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second, // header receive timeout
ExpectContinueTimeout: 1 * time.Second,

// TCP connection settings
DialContext: (&net.Dialer{
Timeout: 30 * time.Second, // TCP connection timeout only
KeepAlive: 30 * time.Second, // TCP Keep-Alive
}).DialContext,

// TLS settings
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},

DisableCompression: false, // keep gzip auto-handling
}

return &http.Client{
Timeout: 60 * time.Second, // total request timeout (connect + send + receive)
Transport: transport,
// redirect policy (default: up to 10 redirects)
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("redirect limit exceeded: %d", len(via))
}
return nil
},
}
}

func main() {
client := newHTTPClient()

req, _ := http.NewRequest("GET", "https://httpbin.org/get", nil)
req.Header.Set("User-Agent", "MyApp/1.0")

resp, err := client.Do(req)
if err != nil {
fmt.Println("request failed:", err)
return
}
defer resp.Body.Close()

var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
fmt.Printf("response: %+v\n", result["headers"])
}

Response Handling Patterns

JSON Decoding and Streaming Responses

package main

import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)

type GitHubRepo struct {
Name string `json:"name"`
Description string `json:"description"`
Stars int `json:"stargazers_count"`
Language string `json:"language"`
UpdatedAt time.Time `json:"updated_at"`
}

// fetchJSON: decode a JSON API response into a struct
func fetchJSON(client *http.Client, url string, target any) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("request creation failed: %w", err)
}
req.Header.Set("Accept", "application/json")

resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()

// handle error status codes
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("HTTP error [%d]: %s", resp.StatusCode, body)
}

// streaming JSON decode (memory efficient)
decoder := json.NewDecoder(resp.Body)
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("JSON decode failed: %w", err)
}
return nil
}

func main() {
client := &http.Client{Timeout: 10 * time.Second}

var repo GitHubRepo
url := "https://api.github.com/repos/golang/go"
if err := fetchJSON(client, url, &repo); err != nil {
fmt.Println("API call failed:", err)
return
}

fmt.Printf("repository: %s\n", repo.Name)
fmt.Printf("description: %s\n", repo.Description)
fmt.Printf("stars: %d\n", repo.Stars)
fmt.Printf("language: %s\n", repo.Language)
fmt.Printf("updated: %s\n", repo.UpdatedAt.Format("2006-01-02"))
}

Retry Pattern — Exponential Backoff

Network errors and transient server errors (5xx) can be resolved by retrying. Exponential backoff is the standard pattern for retrying while avoiding server overload.

package main

import (
"context"
"fmt"
"io"
"math/rand"
"net/http"
"time"
)

// RetryConfig: retry configuration
type RetryConfig struct {
MaxAttempts int
InitialWait time.Duration
MaxWait time.Duration
Multiplier float64
}

// DefaultRetryConfig: default retry settings
var DefaultRetryConfig = RetryConfig{
MaxAttempts: 3,
InitialWait: 500 * time.Millisecond,
MaxWait: 30 * time.Second,
Multiplier: 2.0,
}

// doWithRetry: HTTP request with exponential backoff retry
func doWithRetry(ctx context.Context, client *http.Client, req *http.Request, cfg RetryConfig) (*http.Response, error) {
var lastErr error
wait := cfg.InitialWait

for attempt := 1; attempt <= cfg.MaxAttempts; attempt++ {
// check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}

// execute request
resp, err := client.Do(req.Clone(ctx))
if err == nil {
// check for retriable status codes
if resp.StatusCode < 500 {
return resp, nil // success or client error (4xx)
}
// 5xx: server error — retry
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
resp.Body.Close()
lastErr = fmt.Errorf("server error [%d]: %s", resp.StatusCode, body)
} else {
lastErr = err
}

if attempt == cfg.MaxAttempts {
break
}

// add jitter: randomize within ±20% of wait
jitter := time.Duration(float64(wait) * (0.8 + 0.4*rand.Float64()))
fmt.Printf("attempt %d failed, retrying in %v: %v\n", attempt, jitter, lastErr)

select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(jitter):
}

// increase wait time (capped at MaxWait)
wait = time.Duration(float64(wait) * cfg.Multiplier)
if wait > cfg.MaxWait {
wait = cfg.MaxWait
}
}

return nil, fmt.Errorf("max retries (%d) exceeded: %w", cfg.MaxAttempts, lastErr)
}

func main() {
client := &http.Client{Timeout: 10 * time.Second}
ctx := context.Background()

req, _ := http.NewRequest("GET", "https://httpbin.org/status/200", nil)

resp, err := doWithRetry(ctx, client, req, DefaultRetryConfig)
if err != nil {
fmt.Println("final failure:", err)
return
}
defer resp.Body.Close()
fmt.Println("success! status:", resp.StatusCode)
}

Real-World Example 1 — REST API Client Wrapper

package main

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)

// APIClient: REST API client wrapper
type APIClient struct {
baseURL string
httpClient *http.Client
headers map[string]string
}

func NewAPIClient(baseURL string, token string) *APIClient {
return &APIClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
headers: map[string]string{
"Authorization": "Bearer " + token,
"Content-Type": "application/json",
"Accept": "application/json",
},
}
}

func (c *APIClient) do(ctx context.Context, method, path string, body any) (*http.Response, error) {
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("JSON marshal failed: %w", err)
}
bodyReader = bytes.NewReader(data)
}

fullURL := c.baseURL + path
req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
if err != nil {
return nil, err
}

for k, v := range c.headers {
req.Header.Set(k, v)
}

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}

if resp.StatusCode >= 400 {
defer resp.Body.Close()
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("API error [%d]: %s", resp.StatusCode, errBody)
}
return resp, nil
}

func (c *APIClient) Get(ctx context.Context, path string, params url.Values, result any) error {
if params != nil {
path = path + "?" + params.Encode()
}
resp, err := c.do(ctx, "GET", path, nil)
if err != nil {
return err
}
defer resp.Body.Close()
return json.NewDecoder(resp.Body).Decode(result)
}

func (c *APIClient) Post(ctx context.Context, path string, payload, result any) error {
resp, err := c.do(ctx, "POST", path, payload)
if err != nil {
return err
}
defer resp.Body.Close()
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}

// GitHub API example
type GitHubUser struct {
Login string `json:"login"`
Name string `json:"name"`
PublicRepos int `json:"public_repos"`
Followers int `json:"followers"`
}

func main() {
// GitHub public API works without a token for some endpoints
client := NewAPIClient("https://api.github.com", "")
client.headers["Authorization"] = "" // use public API without token

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

var user GitHubUser
if err := client.Get(ctx, "/users/golang", nil, &user); err != nil {
fmt.Println("API call failed:", err)
return
}

fmt.Printf("user: %s (%s)\n", user.Login, user.Name)
fmt.Printf("public repos: %d\n", user.PublicRepos)
fmt.Printf("followers: %d\n", user.Followers)
}

Real-World Example 2 — File Download with Progress

package main

import (
"fmt"
"io"
"net/http"
"os"
"time"
)

// ProgressWriter: tracks download progress
type ProgressWriter struct {
Total int64
Current int64
LastShow time.Time
}

func (pw *ProgressWriter) Write(p []byte) (n int, err error) {
n = len(p)
pw.Current += int64(n)

// show progress every 500ms
if time.Since(pw.LastShow) > 500*time.Millisecond {
pw.showProgress()
pw.LastShow = time.Now()
}
return n, nil
}

func (pw *ProgressWriter) showProgress() {
if pw.Total > 0 {
pct := float64(pw.Current) / float64(pw.Total) * 100
bar := int(pct / 5) // up to 20-char bar
filled := ""
for i := 0; i < bar; i++ {
filled += "="
}
for i := bar; i < 20; i++ {
filled += " "
}
fmt.Printf("\r[%s] %.1f%% (%d / %d KB)",
filled, pct, pw.Current/1024, pw.Total/1024)
} else {
fmt.Printf("\rdownloading: %d KB", pw.Current/1024)
}
}

// downloadFile: download from URL and show progress
func downloadFile(url, destPath string) error {
client := &http.Client{Timeout: 5 * time.Minute}

resp, err := client.Get(url)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("server error: %s", resp.Status)
}

// write to temp file first (atomic write pattern)
tmpPath := destPath + ".tmp"
f, err := os.Create(tmpPath)
if err != nil {
return fmt.Errorf("file create failed: %w", err)
}
defer func() {
f.Close()
os.Remove(tmpPath) // cleanup temp file on failure
}()

progress := &ProgressWriter{
Total: resp.ContentLength,
LastShow: time.Now(),
}

// track progress with TeeReader while saving to file
tee := io.TeeReader(resp.Body, progress)
if _, err := io.Copy(f, tee); err != nil {
return fmt.Errorf("download failed: %w", err)
}

progress.showProgress()
fmt.Println() // newline

f.Close()

// atomic rename to final path
if err := os.Rename(tmpPath, destPath); err != nil {
return fmt.Errorf("file move failed: %w", err)
}
return nil
}

func main() {
url := "https://golang.org/dl/go1.23.0.src.tar.gz"
dest := "go_source.tar.gz"

fmt.Printf("download starting: %s\n", url)
if err := downloadFile(url, dest); err != nil {
fmt.Println("download failed:", err)
fmt.Println("(check network connection)")
return
}
fmt.Printf("download complete: %s\n", dest)
os.Remove(dest) // cleanup test file
}

Pro Tips

Always call resp.Body.Close(): If you don't close the Body, the TCP connection won't be returned to the connection pool and connections will be exhausted. Even on error, close the Body if it's not nil.

Use context for cancellation: Use http.NewRequestWithContext(ctx, ...) to control timeouts and cancellation via context. The HTTP client's Timeout field serves as a final safety net for the entire request.

Reuse Transport: Creating a new http.Client per request means you can't benefit from connection pooling. Share a single http.Client instance across your application.

Retry 5xx, not 4xx: Server errors (5xx) may be transient and worth retrying, but client errors (4xx) indicate a bad request — retrying them is pointless.