Cuando realizas una petición HTTP con http.Client, no solo estás obteniendo datos; estás gestionando el ciclo de vida de una conexión TCP. El campo Body de una http.Response es un io.ReadCloser que actúa como la interfaz para leer la respuesta y, lo más importante, como el mecanismo para liberar la conexión subyacente.
En Go, las conexiones TCP se reutilizan mediante HTTP Keep-Alive para evitar el coste de realizar un nuevo handshake en cada petición. Sin embargo, para que el http.Transport (el motor interno del http.Client) sepa que una conexión puede volver al pool de conexiones inactivas (idle pool), el cuerpo de la respuesta debe ser leído completamente hasta alcanzar el EOF y luego ser cerrado. Si cierras el cuerpo sin leerlo todo, la conexión se considera “sucia” y el runtime la cerrará físicamente en lugar de reutilizarla. Esto provoca un agotamiento de sockets y latencia innecesaria.
Para usar este patrón de forma segura, siempre debes: 1) Verificar que la respuesta no sea nula y que no haya habido un error de red; 2) Verificar el StatusCode antes de procesar datos; 3) Limitar la cantidad de bytes que lees para evitar ataques de desbordamiento de memoria; y 4) Asegurar el drenaje de la conexión si decides abortar la lectura.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"time"
)
// fetchResource encapsula la lógica robusta para obtener datos de una URL.
func fetchResource(client *http.Client, url string, maxBytes int64) ([]byte, error) {
resp, err := client.Get(url)
if err != nil {
return nil, err
}
// Siempre cerramos el body para liberar descriptores de archivos/sockets.
// El defer garantiza que el recurso se libere incluso si la función retorna por error.
defer resp.Body.Close()
// 1. Validar el status antes de intentar procesar el cuerpo.
if resp.StatusCode != http.StatusOK {
// 2. IMPORTANTE: Drenar el cuerpo antes de retornar.
// Si el servidor envió un error con un cuerpo grande, necesitamos "limpiarlo"
// para que la conexión TCP sea reutilizable en la siguiente petición.
_, _ = io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("servidor respondió con status %d", resp.StatusCode)
}
// 3. Proteger la memoria usando io.LimitReader.
// Evitamos que un servidor malicioso nos envíe gigabytes de datos que saturen la RAM.
limitReader := io.LimitReader(resp.Body, maxBytes)
data, err := io.ReadAll(limitReader)
if err != nil {
return nil, err
}
// Si llegamos aquí, leímos todo (o alcanzamos el límite) y la conexión está limpia.
return data, nil
}
func main() {
// Configuramos un servidor de prueba para simular distintos escenarios.
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/ok":
w.WriteHeader(http.StatusOK)
w.Write([]byte("contenido válido"))
case "/error":
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("error interno del servidor"))
case "/huge":
w.WriteHeader(http.StatusOK)
// Simulamos un flujo de datos masivo.
w.Write(make([]byte, 10*1024*1024)) // 10 MB
}
}))
defer ts.Close()
client := &http.Client{Timeout: 2 * time.Second}
// Escenario A: Éxito total.
data, err := fetchResource(client, ts.URL+"/ok", 1024)
fmt.Printf("Escenario OK: %s | Error: %v\n", string(data), err)
// Escenario B: Error de status (requiere drenar para reutilizar conexión).
_, err = fetchResource(client, ts.URL+"/error", 1024)
fmt.Printf("Escenario Error Status: %v\n", err)
// Escenario C: Protección contra payloads masivos.
data, err = fetchResource(client, ts.URL+"/huge", 512)
fmt.Printf("Escenario Limitado (512B): %d bytes leídos | Error: %v\n", len(data), err)
}
Análisis del código
En la función fetchResource, la llamada a defer resp.Body.Close() es la primera línea de defensa contra fugas de memoria (memory leaks) y de descriptores de archivos. Sin embargo, la verdadera magia de la eficiencia ocurre en el manejo de errores.
Fíjate en el bloque if resp.StatusCode != http.StatusOK. Cuando el servidor devuelve un error (como un 500 o un 404), el cuerpo de esa respuesta puede contener información útil o ser simplemente basura. Si simplemente hacemos return err, dejamos bytes acumulados en el buffer de red. Al usar io.Copy(io.Discard, resp.Body), estamos consumiendo todos los bytes disponibles de forma eficiente (sin asignarlos a variables) y moviéndolos hacia el “descarte” de Go. Esto permite que el Transport vea el EOF y devuelva la conexión al pool.
Para la seguridad, implementamos io.LimitReader(resp.Body, maxBytes). Esta es una medida de protección crítica en sistemas distribuidos. Si un servicio externo tiene un problema o es malicioso, podría enviar un flujo de datos infinito. Sin el LimitReader, io.ReadAll intentaría asignar toda esa memoria, provocando un pánico de out of memory (OOM) y matando tu proceso.
El error frecuente
Un error muy común ocurre cuando intentas optimizar el código y olvidas el drenado en las rutas de error.
// --- CÓDIGO CON BUG ---
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// ERROR: Solo cerramos el body, pero no lo drenamos.
// La conexión TCP no se puede reutilizar y se cierra por completo.
// En una carga alta, esto agotará los sockets del sistema operativo.
return fmt.Errorf("error: %d", resp.StatusCode)
}
Si ejecutas este patrón bajo una carga considerable de peticiones, verás que tu aplicación empieza a fallar con errores de conexión o “too many open files”, ya que el sistema operativo se queda esperando a que las conexiones en estado TIME_WAIT se liberen, en lugar de reutilizar las existentes.
N° 187