El http.Client es la estructura de alto nivel encargada de orquestar las peticiones HTTP, pero no es la que realmente gestiona el protocolo. Su función es servir de fachada para un http.RoundTripper (normalmente un *http.Transport), que es donde ocurre el trabajo sucio: el establecimiento de conexiones TCP, el handshake TLS y el mantenimiento del pool de conexiones. El error más común al empezar es usar http.Get o http.Post, funciones que utilizan el http.DefaultClient. Este cliente tiene un Timeout de cero, lo que significa que una respuesta lenta o un socket colgado pueden mantener tu goroutine y la conexión abierta indefinidamente, consumiendo recursos hasta que el proceso colapse.
Para evitar esto, en producción nunca debes usar el cliente por defecto. Debes instanciar un http.Client personalizado con un Timeout definido; ten en cuenta que este timeout es “end-to-end”: abarca desde que se inicia la petición hasta que el Body se ha leído completamente. Si necesitas un control granular sobre las fases de la conexión (como cuánto esperar al handshake de TLS o cuánto dejar una conexión inactiva en el pool), debes configurar un http.Transport a medida y asignarlo al cliente.
Finalmente, la eficiencia en escala depende de la reutilización. El Transport mantiene un pool de conexiones keep-alive. Si creas un http.Client nuevo en cada petición, no solo estás desperdiciando ciclos de CPU en nuevos handshakes TLS, sino que estás dejando miles de sockets en estado TIME_WAIT, lo que eventualmente agotará los descriptores de archivos del sistema operativo.
package main
import (
"context"
"fmt"
"io"
"net"
"net/http"
"time"
)
// APIClient encapsula el cliente HTTP para asegurar su reutilización.
type APIClient struct {
client *http.Client
}
func NewAPIClient() *APIClient {
// Configuramos el transporte para un control fino sobre el pool y la red.
transport := &http.Transport{
// DialContext controla cómo se establecen las conexiones TCP.
DialContext: (&net.Dialer{
Timeout: 5 * time.Second, // Tiempo máximo para establecer la conexión TCP.
KeepAlive: 30 * time.Second, // Keep-alive a nivel de socket TCP.
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second, // Tiempo para recibir los headers de respuesta.
IdleConnTimeout: 90 * time.Second, // Cuánto tiempo vive una conexión en el pool.
MaxIdleConns: 100, // Total de conexiones en el pool.
MaxIdleConnsPerHost: 20, // Crucial para evitar bottlenecks hacia un mismo host.
}
return &APIClient{
client: &http.Client{
Transport: transport,
Timeout: 30 * time.Second, // Timeout total end-to-end.
},
}
}
func (c *APIClient) FetchData(ctx context.Context, url string) error {
// Usamos la petición con contexto para permitir cancelaciones externas.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("error en la petición: %w", err)
}
// Es vital asegurar el cierre del cuerpo para liberar la conexión al pool.
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status inesperado: %d", resp.StatusCode)
}
// Debemos leer el cuerpo hasta el final (o descartarlo) para que la
// conexión pueda ser reutilizada por el Transport.
_, err = io.Copy(io.Discard, resp.Body)
if err != nil {
return fmt.Errorf("error leyendo el cuerpo: %w", err)
}
fmt.Println("Petición completada exitosamente")
return nil
}
func main() {
api := NewAPIClient()
ctx := context.Background()
// Simulamos peticiones consecutivas usando el mismo cliente.
for i := 0; i < 3; i++ {
err := api.FetchData(ctx, "https://httpbin.org/get")
if err != nil {
fmt.Printf("Error en iteración %d: %v\n", i, err)
return
}
}
}
Análisis del diseño
En el ejemplo anterior, la clave de la robustez reside en la separación de responsabilidades entre el Client y el Transport. Al configurar MaxIdleConnsPerHost en 20, permitimos que el cliente mantenga múltiples conexiones abiertas hacia el mismo servidor (en este caso httpbin.org), evitando el coste de negociación TCP/TLS en cada iteración del bucle main.
Fíjate en el uso de DialContext dentro del Transport. No estamos simplemente definiendo un timeout genérico, sino controlando específicamente cuánto tiempo permitimos que el sistema operativo intente establecer la conexión TCP antes de fallar. Esto es vital en entornos de red inestables.
Un detalle crítico de nivel senior es el manejo del resp.Body. No basta con hacer defer resp.Body.Close(). Para que el http.Transport pueda marcar la conexión como “idónea para reutilización” y devolverla al pool, el programa debe haber leído el cuerpo hasta el final (io.EOF). Si cierras el cuerpo sin haberlo leído completamente, el Transport no puede garantizar la integridad de los datos para la siguiente petición sobre esa misma conexión y, por seguridad, la cerrará, forzando una nueva conexión TCP en la siguiente llamada.
El error frecuente
El error más insidioso en sistemas de alto rendimiento es instanciar el cliente dentro de una función que se ejecuta repetidamente:
// ANTI-PATTERN: NUNCA HAGAS ESTO en un handler o bucle de producción.
func BadFetch(url string) {
// Se crea un nuevo transport y un nuevo cliente cada vez.
// No hay reutilización de conexiones (Keep-Alive).
// Cada llamada abre un nuevo socket y lo deja en TIME_WAIT.
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err == nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
Este patrón provocará un error de “too many open files” o un agotamiento de puertos efímeros en pocos minutos bajo carga moderada. Al no reutilizar el Transport, anulas la capacidad del pool de conexiones y obligas al kernel a gestionar una montaña de sockets en espera de expirar, lo que degrada drásticamente el rendimiento de toda la máquina.
N° 185