Cliente HTTP en Go: http.Client, Transport, timeouts y peticiones con contexto

El paquete net/http incluye también un cliente HTTP completo. El error más habitual es usar http.DefaultClient en producción: no tiene timeout, lo que puede dejar goroutines bloqueadas indefinidamente. Un cliente bien configurado con timeouts, transporte personalizado y contexto de cancelación es imprescindible en cualquier servicio real.

Get y Post básicos

package main

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

func main() {
    // GET simple (sin timeout — solo para ejemplos)
    resp, err := http.Get("https://httpbin.org/get")
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)
    fmt.Printf("Status: %dn%sn", resp.StatusCode, body)
}

http.Client con timeout

Define siempre un cliente propio con timeout. El timeout cubre todo el ciclo de vida de la petición: dial, handshake TLS, envío y lectura de la respuesta:

var clienteHTTP = &http.Client{
    Timeout: 10 * time.Second,
}

func obtenerDatos(url string) ([]byte, error) {
    resp, err := clienteHTTP.Get(url)
    if err != nil {
        return nil, fmt.Errorf("GET %s: %w", url, err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("GET %s: status %d", url, resp.StatusCode)
    }

    return io.ReadAll(resp.Body)
}

Petición con contexto y cancelación

http.NewRequestWithContext une el contexto con la petición. Cuando el contexto se cancela o expira, la petición se interrumpe limpiamente:

func buscarUsuario(ctx context.Context, id int) (*Usuario, error) {
    url := fmt.Sprintf("https://api.example.com/usuarios/%d", id)

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, err
    }
    req.Header.Set("Authorization", "Bearer "+token)
    req.Header.Set("Accept", "application/json")

    resp, err := clienteHTTP.Do(req)
    if err != nil {
        return nil, fmt.Errorf("petición fallida: %w", err)
    }
    defer resp.Body.Close()

    var u Usuario
    if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
        return nil, fmt.Errorf("decode: %w", err)
    }
    return &u, nil
}

POST con body JSON

func crearUsuario(ctx context.Context, u NuevoUsuario) (*Usuario, error) {
    datos, err := json.Marshal(u)
    if err != nil {
        return nil, err
    }

    req, err := http.NewRequestWithContext(ctx, http.MethodPost,
        "https://api.example.com/usuarios", bytes.NewReader(datos))
    if err != nil {
        return nil, err
    }
    req.Header.Set("Content-Type", "application/json")

    resp, err := clienteHTTP.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusCreated {
        cuerpo, _ := io.ReadAll(resp.Body)
        return nil, fmt.Errorf("error %d: %s", resp.StatusCode, cuerpo)
    }

    var resultado Usuario
    json.NewDecoder(resp.Body).Decode(&resultado)
    return &resultado, nil
}

Transport personalizado

http.Transport controla el pool de conexiones, los timeouts a nivel de TCP y TLS, y la compresión. En servicios con alta concurrencia conviene ajustarlo:

var clienteProduccion = &http.Client{
    Timeout: 30 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 20,
        IdleConnTimeout:     90 * time.Second,
        TLSHandshakeTimeout: 5 * time.Second,
        DialContext: (&net.Dialer{
            Timeout:   5 * time.Second,
            KeepAlive: 30 * time.Second,
        }).DialContext,
    },
}

Reintentos con backoff exponencial

func getConReintentos(ctx context.Context, url string, maxReintentos int) ([]byte, error) {
    var ultimo error
    for intento := 0; intento < maxReintentos; intento++ {
        if intento > 0 {
            espera := time.Duration(intento*intento) * 100 * time.Millisecond
            select {
            case <-time.After(espera):
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }

        req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
        resp, err := clienteHTTP.Do(req)
        if err != nil {
            ultimo = err
            continue
        }
        defer resp.Body.Close()

        if resp.StatusCode >= 500 {
            ultimo = fmt.Errorf("status %d", resp.StatusCode)
            continue
        }

        return io.ReadAll(resp.Body)
    }
    return nil, fmt.Errorf("tras %d reintentos: %w", maxReintentos, ultimo)
}

No reutilices http.DefaultTransport sin modificarlo: sus valores por defecto de MaxIdleConnsPerHost (2) son demasiado bajos para servicios con alta concurrencia, lo que provoca cuellos de botella en el pool de conexiones.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP