Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

HTTP & Web Development in Go

Go’s net/http package is powerful enough for production use without external dependencies. This sets Go apart from most languages where you need a framework (Express, Flask, Spring) for anything beyond a toy server. Companies like Cloudflare, Dropbox, and Netflix run production services using just the standard library’s HTTP server, sometimes with a lightweight router on top. This chapter covers building robust web services from the ground up.

The net/http Package

Basic HTTP Server

package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {
    http.HandleFunc("/", homeHandler)
    http.HandleFunc("/api/users", usersHandler)
    
    log.Println("Server starting on :8080")
    // ListenAndServe blocks forever; log.Fatal prints the error and exits
    // if the server fails to start (port in use, permissions, etc.)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

func homeHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Welcome to the API!")
}

func usersHandler(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case http.MethodGet:
        getUsers(w, r)
    case http.MethodPost:
        createUser(w, r)
    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}

http.Handler Interface

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}
Any type that implements ServeHTTP can handle HTTP requests:
type APIHandler struct {
    db *sql.DB
}

func (h *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // Handle request with access to h.db
}

func main() {
    handler := &APIHandler{db: connectDB()}
    http.Handle("/api/", handler)
    http.ListenAndServe(":8080", nil)
}

JSON APIs

Encoding JSON Responses

type User struct {
    ID        int       `json:"id"`
    Name      string    `json:"name"`
    Email     string    `json:"email"`
    CreatedAt time.Time `json:"created_at"`
}

type Response struct {
    Data    interface{} `json:"data,omitempty"`
    Error   string      `json:"error,omitempty"`
    Message string      `json:"message,omitempty"`
}

func respondJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    
    if err := json.NewEncoder(w).Encode(data); err != nil {
        log.Printf("Error encoding response: %v", err)
    }
}

func respondError(w http.ResponseWriter, status int, message string) {
    respondJSON(w, status, Response{Error: message})
}

func getUserHandler(w http.ResponseWriter, r *http.Request) {
    user := User{
        ID:        1,
        Name:      "John Doe",
        Email:     "john@example.com",
        CreatedAt: time.Now(),
    }
    
    respondJSON(w, http.StatusOK, Response{Data: user})
}

Decoding JSON Requests

type CreateUserRequest struct {
    Name     string `json:"name" validate:"required,min=2,max=100"`
    Email    string `json:"email" validate:"required,email"`
    Password string `json:"password" validate:"required,min=8"`
}

func createUserHandler(w http.ResponseWriter, r *http.Request) {
    // Limit request body size
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB
    
    var req CreateUserRequest
    decoder := json.NewDecoder(r.Body)
    decoder.DisallowUnknownFields() // Strict parsing
    
    if err := decoder.Decode(&req); err != nil {
        respondError(w, http.StatusBadRequest, "Invalid JSON: "+err.Error())
        return
    }
    
    // Validate (using validator package)
    if err := validate.Struct(req); err != nil {
        respondError(w, http.StatusBadRequest, "Validation failed: "+err.Error())
        return
    }
    
    // Create user...
    user := createUser(req)
    respondJSON(w, http.StatusCreated, Response{Data: user})
}

Routing

Standard Library Router (Go 1.22+)

Go 1.22 introduced enhanced routing patterns:
func main() {
    mux := http.NewServeMux()
    
    // Method-specific routing (Go 1.22+)
    mux.HandleFunc("GET /api/users", listUsers)
    mux.HandleFunc("POST /api/users", createUser)
    mux.HandleFunc("GET /api/users/{id}", getUser)
    mux.HandleFunc("PUT /api/users/{id}", updateUser)
    mux.HandleFunc("DELETE /api/users/{id}", deleteUser)
    
    // Wildcard patterns
    mux.HandleFunc("GET /files/{path...}", serveFiles)
    
    http.ListenAndServe(":8080", mux)
}

func getUser(w http.ResponseWriter, r *http.Request) {
    // Extract path parameter (Go 1.22+)
    id := r.PathValue("id")
    
    // Or manually for older Go versions
    // id := strings.TrimPrefix(r.URL.Path, "/api/users/")
    
    user, err := findUser(id)
    if err != nil {
        respondError(w, http.StatusNotFound, "User not found")
        return
    }
    
    respondJSON(w, http.StatusOK, Response{Data: user})
}

Custom Router

type Router struct {
    routes map[string]map[string]http.HandlerFunc
}

func NewRouter() *Router {
    return &Router{
        routes: make(map[string]map[string]http.HandlerFunc),
    }
}

func (router *Router) Handle(method, path string, handler http.HandlerFunc) {
    if router.routes[path] == nil {
        router.routes[path] = make(map[string]http.HandlerFunc)
    }
    router.routes[path][method] = handler
}

func (router *Router) GET(path string, handler http.HandlerFunc) {
    router.Handle(http.MethodGet, path, handler)
}

func (router *Router) POST(path string, handler http.HandlerFunc) {
    router.Handle(http.MethodPost, path, handler)
}

func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if methods, ok := router.routes[r.URL.Path]; ok {
        if handler, ok := methods[r.Method]; ok {
            handler(w, r)
            return
        }
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }
    http.NotFound(w, r)
}

Middleware

Middleware are functions that wrap handlers to add functionality. Think of middleware as layers in an onion: each request passes through the outer layers (logging, auth, CORS) before reaching the core handler, and the response passes back out through the same layers. This is the Go version of the decorator pattern, and it is the standard way to add cross-cutting concerns like authentication, logging, rate limiting, and panic recovery.

Middleware Pattern

type Middleware func(http.Handler) http.Handler

func Chain(h http.Handler, middlewares ...Middleware) http.Handler {
    for i := len(middlewares) - 1; i >= 0; i-- {
        h = middlewares[i](h)
    }
    return h
}

Logging Middleware

func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        // Wrap ResponseWriter to capture status code
        wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
        
        next.ServeHTTP(wrapped, r)
        
        log.Printf(
            "%s %s %d %s",
            r.Method,
            r.URL.Path,
            wrapped.statusCode,
            time.Since(start),
        )
    })
}

type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

Recovery Middleware

func RecoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("Panic recovered: %v\n%s", err, debug.Stack())
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        
        next.ServeHTTP(w, r)
    })
}

CORS Middleware

func CORSMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
        
        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusOK)
            return
        }
        
        next.ServeHTTP(w, r)
    })
}

Authentication Middleware

func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token == "" {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        
        // Validate token
        claims, err := validateToken(strings.TrimPrefix(token, "Bearer "))
        if err != nil {
            http.Error(w, "Invalid token", http.StatusUnauthorized)
            return
        }
        
        // Add claims to context
        ctx := context.WithValue(r.Context(), userClaimsKey{}, claims)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Rate Limiting Middleware

func RateLimitMiddleware(rps int) Middleware {
    limiter := rate.NewLimiter(rate.Limit(rps), rps)
    
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if !limiter.Allow() {
                http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

Using Middleware

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", usersHandler)
    mux.HandleFunc("/api/orders", ordersHandler)
    
    // Apply middleware chain
    handler := Chain(
        mux,
        RecoveryMiddleware,
        LoggingMiddleware,
        CORSMiddleware,
        RateLimitMiddleware(100),
    )
    
    http.ListenAndServe(":8080", handler)
}

HTTP Server Configuration

Production-Ready Server

func main() {
    mux := setupRoutes()
    
    srv := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
        MaxHeaderBytes: 1 << 20, // 1MB
    }
    
    // Graceful shutdown
    go func() {
        sigChan := make(chan os.Signal, 1)
        signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
        <-sigChan
        
        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()
        
        log.Println("Shutting down server...")
        if err := srv.Shutdown(ctx); err != nil {
            log.Printf("Server shutdown error: %v", err)
        }
    }()
    
    log.Printf("Server starting on %s", srv.Addr)
    if err := srv.ListenAndServe(); err != http.ErrServerClosed {
        log.Fatal(err)
    }
    
    log.Println("Server stopped")
}

TLS Configuration

func main() {
    tlsConfig := &tls.Config{
        MinVersion: tls.VersionTLS12,
        CipherSuites: []uint16{
            tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
            tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
        },
    }
    
    srv := &http.Server{
        Addr:      ":443",
        Handler:   mux,
        TLSConfig: tlsConfig,
    }
    
    log.Fatal(srv.ListenAndServeTLS("cert.pem", "key.pem"))
}

HTTP Client

Basic Client

Pitfall — Using http.DefaultClient in Production: The default http.Get, http.Post, and http.DefaultClient have no timeout. A slow or unresponsive server will cause your goroutine to hang indefinitely, eventually exhausting resources. Always create a custom client with explicit timeouts:
// WRONG: no timeout, hangs forever on slow servers
resp, err := http.Get("https://api.example.com/users/1")

// RIGHT: always set timeouts
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get("https://api.example.com/users/1")
func fetchUser(id string) (*User, error) {
    resp, err := http.Get("https://api.example.com/users/" + id)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close() // Always close: returns connection to pool for reuse
    
    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
    }
    
    var user User
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        return nil, err
    }
    
    return &user, nil
}

Production HTTP Client

A production HTTP client is like a well-tuned car engine: it needs proper connection pooling (how many connections to keep warm), timeouts (when to give up), and transport settings (how to manage the underlying TCP connections). The http.Transport is the engine; the http.Client is the driver interface.
type APIClient struct {
    baseURL    string
    httpClient *http.Client
    apiKey     string
}

func NewAPIClient(baseURL, apiKey string) *APIClient {
    return &APIClient{
        baseURL: baseURL,
        apiKey:  apiKey,
        httpClient: &http.Client{
            Timeout: 30 * time.Second, // Overall request timeout
            Transport: &http.Transport{
                MaxIdleConns:        100, // Total idle connections across all hosts
                MaxIdleConnsPerHost: 10,  // Idle connections per host (tune for your traffic)
                IdleConnTimeout:     90 * time.Second, // How long idle connections survive
            },
        },
    }
}

func (c *APIClient) do(ctx context.Context, method, path string, body interface{}) (*http.Response, error) {
    var bodyReader io.Reader
    if body != nil {
        jsonBody, err := json.Marshal(body)
        if err != nil {
            return nil, err
        }
        bodyReader = bytes.NewReader(jsonBody)
    }
    
    req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
    if err != nil {
        return nil, err
    }
    
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer "+c.apiKey)
    req.Header.Set("User-Agent", "MyApp/1.0")
    
    return c.httpClient.Do(req)
}

func (c *APIClient) GetUser(ctx context.Context, id string) (*User, error) {
    resp, err := c.do(ctx, http.MethodGet, "/users/"+id, nil)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    if resp.StatusCode == http.StatusNotFound {
        return nil, ErrNotFound
    }
    
    if resp.StatusCode != http.StatusOK {
        body, _ := io.ReadAll(resp.Body)
        return nil, fmt.Errorf("API error: %d - %s", resp.StatusCode, string(body))
    }
    
    var user User
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        return nil, err
    }
    
    return &user, nil
}

func (c *APIClient) CreateUser(ctx context.Context, user *CreateUserRequest) (*User, error) {
    resp, err := c.do(ctx, http.MethodPost, "/users", user)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    if resp.StatusCode != http.StatusCreated {
        return nil, parseAPIError(resp)
    }
    
    var created User
    if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
        return nil, err
    }
    
    return &created, nil
}

Retry with Exponential Backoff

func (c *APIClient) doWithRetry(ctx context.Context, method, path string, body interface{}) (*http.Response, error) {
    maxRetries := 3
    baseDelay := 100 * time.Millisecond
    
    var lastErr error
    for attempt := 0; attempt < maxRetries; attempt++ {
        if attempt > 0 {
            delay := baseDelay * time.Duration(1<<uint(attempt-1))
            select {
            case <-ctx.Done():
                return nil, ctx.Err()
            case <-time.After(delay):
            }
        }
        
        resp, err := c.do(ctx, method, path, body)
        if err != nil {
            lastErr = err
            continue
        }
        
        // Don't retry on client errors
        if resp.StatusCode >= 400 && resp.StatusCode < 500 {
            return resp, nil
        }
        
        // Retry on server errors
        if resp.StatusCode >= 500 {
            resp.Body.Close()
            lastErr = fmt.Errorf("server error: %d", resp.StatusCode)
            continue
        }
        
        return resp, nil
    }
    
    return nil, fmt.Errorf("max retries exceeded: %w", lastErr)
}

Chi Router

import "github.com/go-chi/chi/v5"

func main() {
    r := chi.NewRouter()
    
    // Built-in middleware
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.Timeout(60 * time.Second))
    
    // Routes
    r.Get("/", homeHandler)
    
    // Route groups
    r.Route("/api/v1", func(r chi.Router) {
        r.Use(AuthMiddleware)
        
        r.Route("/users", func(r chi.Router) {
            r.Get("/", listUsers)
            r.Post("/", createUser)
            r.Get("/{id}", getUser)
            r.Put("/{id}", updateUser)
            r.Delete("/{id}", deleteUser)
        })
    })
    
    http.ListenAndServe(":8080", r)
}

func getUser(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    // ...
}

Gin Framework

import "github.com/gin-gonic/gin"

func main() {
    r := gin.Default() // Includes logger and recovery
    
    // Middleware
    r.Use(CORSMiddleware())
    
    // Routes
    api := r.Group("/api/v1")
    {
        api.Use(AuthMiddleware())
        
        users := api.Group("/users")
        {
            users.GET("", listUsers)
            users.POST("", createUser)
            users.GET("/:id", getUser)
            users.PUT("/:id", updateUser)
            users.DELETE("/:id", deleteUser)
        }
    }
    
    r.Run(":8080")
}

func getUser(c *gin.Context) {
    id := c.Param("id")
    
    user, err := findUser(id)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
        return
    }
    
    c.JSON(http.StatusOK, user)
}

func createUser(c *gin.Context) {
    var req CreateUserRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    
    user := createUserFromRequest(req)
    c.JSON(http.StatusCreated, user)
}

Echo Framework

import "github.com/labstack/echo/v4"

func main() {
    e := echo.New()
    
    // Middleware
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())
    
    // Routes
    api := e.Group("/api/v1")
    api.Use(AuthMiddleware)
    
    api.GET("/users", listUsers)
    api.POST("/users", createUser)
    api.GET("/users/:id", getUser)
    
    e.Logger.Fatal(e.Start(":8080"))
}

func getUser(c echo.Context) error {
    id := c.Param("id")
    
    user, err := findUser(id)
    if err != nil {
        return c.JSON(http.StatusNotFound, map[string]string{"error": "not found"})
    }
    
    return c.JSON(http.StatusOK, user)
}

Interview Questions

  • http.Handle takes an http.Handler interface
  • http.HandleFunc takes a function with signature func(w http.ResponseWriter, r *http.Request)
HandleFunc is a convenience wrapper that converts the function to a HandlerFunc type which implements Handler.
Multiple approaches:
  1. Server-level: Set ReadTimeout, WriteTimeout on http.Server
  2. Request-level: Use context.WithTimeout with http.NewRequestWithContext
  3. Handler-level: Use http.TimeoutHandler wrapper
  4. Middleware: Create custom timeout middleware
HTTP responses must have their body closed to:
  • Release the connection back to the pool for reuse
  • Prevent resource leaks (file descriptors, memory)
  • Allow the transport to reuse connections (keep-alive)
  • Set appropriate server timeouts (ReadTimeout, WriteTimeout, IdleTimeout)
  • Use http.MaxBytesReader to limit request body size
  • Implement request deadlines with context
  • Use rate limiting middleware

Summary

TopicKey Points
net/httpBuilt-in, production-ready, Handler interface
RoutingGo 1.22+ patterns, or use Chi/Gin/Echo
MiddlewareChain pattern, logging, auth, recovery
JSONEncoder/Decoder, validation, proper error handling
ClientTimeouts, connection pooling, retry logic
SecurityTLS, rate limiting, input validation
Pitfall — Not Draining Response Bodies: If you read only part of an HTTP response body (or skip reading it entirely) without closing it, the underlying TCP connection cannot be reused. Always drain and close the body, even for error responses:
resp, err := client.Do(req)
if err != nil {
    return err
}
defer func() {
    // Drain any unread body so the connection can be reused
    io.Copy(io.Discard, resp.Body)
    resp.Body.Close()
}()
Pitfall — Goroutine Leak in HTTP Handlers: If your handler spawns a goroutine that outlives the request (e.g., for background processing) and that goroutine holds a reference to the request context or response writer, you get undefined behavior. The ResponseWriter is invalid after ServeHTTP returns. If you need background work, copy the data you need and use a fresh context.Background().

Interview Deep-Dive

Strong Answer:
  • The net/http standard library provides: a production-grade HTTP server with configurable timeouts, keep-alive, TLS, and graceful shutdown; the http.Handler interface (one method: ServeHTTP) which is the foundation of Go’s composable middleware ecosystem; built-in connection pooling in the HTTP client; and since Go 1.22, method-based routing with path parameters (GET /users/{id}).
  • What it lacks: Go 1.22 routing covers most cases, but before that, the default mux only did prefix matching without method dispatch, which was insufficient for REST APIs. Other missing pieces: route groups with shared middleware, built-in request validation/binding, and automatic OPTIONS/CORS handling.
  • When to use a framework: Chi is my default choice when I need route groups, middleware chaining, and URL parameters on Go versions before 1.22. Chi is just a router that wraps the standard http.Handler interface, so your handlers are portable. Gin is appropriate when you want performance-optimized routing (radix tree), built-in JSON binding/validation, and your team is comfortable with Gin’s non-standard *gin.Context instead of http.ResponseWriter + *http.Request. Echo is similar to Gin in philosophy.
  • My recommendation for new projects: start with the standard library plus Go 1.22 routing. Add Chi if you need route groups or are on an older Go version. Only reach for Gin/Echo if your team already knows them or you need their specific middleware ecosystem.
Follow-up: Explain the middleware pattern in Go. How does func(http.Handler) http.Handler enable composable middleware?The type func(http.Handler) http.Handler is a function that takes a handler and returns a new handler that wraps the original. This is the decorator pattern applied to HTTP handling. Each middleware adds behavior (logging, auth, CORS, rate limiting) before or after calling the inner handler. Because every middleware has the same signature, they compose naturally. A Chain helper reverses this nesting for readability. The beauty is that any middleware works with any handler from any library, because they all speak the same http.Handler interface. This is why Go does not need a monolithic framework — the interface IS the framework.
Strong Answer:
  • First, limit the request body size. An attacker can send a multi-gigabyte body to exhaust memory. Use http.MaxBytesReader(w, r.Body, 1<<20) to cap at 1MB (or whatever is appropriate). This replaces r.Body with a reader that returns an error when the limit is exceeded.
  • Second, use json.NewDecoder(r.Body).Decode(&req) instead of io.ReadAll + json.Unmarshal. The decoder streams the JSON instead of loading the entire body into memory, and with MaxBytesReader, it fails early on oversized payloads.
  • Third, call decoder.DisallowUnknownFields() to reject JSON with fields not in your struct. This catches typos and prevents unexpected data from silently being accepted.
  • Fourth, validate the decoded struct. Use a validation library like go-playground/validator with struct tags (validate:"required,email"), or write explicit validation logic. Never trust client input.
  • Fifth, prevent JSON injection: always set Content-Type: application/json on responses, never interpolate user input into JSON strings manually, and sanitize any data that might be rendered in HTML contexts.
  • Sixth, handle the error from json.NewEncoder(w).Encode(response). If the client disconnects mid-response, this write can fail, and you want to log it rather than crash.
Follow-up: What are the critical timeout settings on http.Server and what happens if you do not set them?Without timeouts, a slow client can hold a connection open indefinitely, exhausting your server’s file descriptors and goroutines. ReadTimeout limits how long the server waits to read the full request. WriteTimeout limits how long the server allows for writing the response. IdleTimeout controls how long keep-alive connections stay open between requests. ReadHeaderTimeout (often overlooked) limits reading just the headers, protecting against slowloris attacks. In production, I set ReadTimeout: 5s, WriteTimeout: 10s, IdleTimeout: 120s, ReadHeaderTimeout: 2s. Without these, a single slow or malicious client can consume a goroutine and connection slot forever.
Strong Answer:
  • http.DefaultClient has Timeout: 0, which means requests can block indefinitely. If the target server is slow or unresponsive, your goroutine hangs forever. In a service handling concurrent requests, this quickly leads to goroutine exhaustion.
  • The production configuration includes: Timeout: 30 * time.Second on the client (overall request timeout), and detailed transport settings: MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second.
  • Additionally, always use http.NewRequestWithContext(ctx, method, url, body) to create requests with context-based cancellation. This ties the request to the caller’s context, so if the caller times out or cancels, the HTTP request is aborted immediately.
  • Critical detail: always read and close the response body, even on error responses. If you do not drain the body, the underlying TCP connection cannot be reused. The pattern is: defer resp.Body.Close() and for error cases: io.Copy(io.Discard, resp.Body).
Follow-up: How would you implement retry with exponential backoff for HTTP requests, and what requests should you NOT retry?Retry only idempotent requests: GET, PUT, DELETE. Never retry POST unless the API supports idempotency keys. Retry on server errors (5xx) and network errors, but not on client errors (4xx). The backoff formula is baseDelay * 2^attempt + jitter, where jitter prevents thundering herd. Always check ctx.Done() between retries so cancellation is respected. And always close the response body of failed attempts before retrying, or you leak connections.