HTTP con requests: Session, timeout y retry en producción

La librería requests no es parte de la biblioteca estándar, pero en la práctica lleva más de una década siendo la forma de hacer HTTP en Python. Su diseño está construido alrededor de un principio claro: las operaciones HTTP deben leer como el protocolo mismo, no como una batalla con sockets y codificación de bytes.

Cuando haces requests.get(url), obtienes un objeto Response que encapsula todo: código de estado, cabeceras, cuerpo —ya sea texto, JSON o binario—. La magia es que el encoding de respuesta, la deserialización JSON y el manejo de redirects están resueltos por defecto, y tú decides cuándo apartarte de esos defaults.

El detalle más ignorado y más crítico: el parámetro timeout. Sin él, tu código puede bloquearse indefinidamente si el servidor no responde. Esto no es un edge case raro —es exactamente lo que pasa cuando un endpoint está saturado o hay un problema de red. En producción, un thread o proceso colgado es un incidente esperando materializarse. timeout=(3.05, 27) es la forma idiomática: el primer valor es el connect timeout (cuánto esperar para establecer la conexión TCP), el segundo es el read timeout (cuánto esperar por el primer byte de respuesta).

Para una llamada aislada está bien usar requests.get() directamente. Pero cuando haces múltiples requests al mismo host, requests.Session() es lo correcto: reutiliza conexiones TCP subyacentes a través de urllib3, propaga cookies automáticamente entre requests, y te permite definir headers y auth una sola vez en lugar de en cada llamada. La diferencia de latencia en llamadas consecutivas al mismo endpoint es medible.

response.raise_for_status() merece mención especial. El protocolo HTTP no considera un 404 o 500 como un error de red —la conexión fue exitosa, el servidor respondió. Por eso requests no lanza excepción automáticamente en 4xx/5xx. raise_for_status() es el contrato explícito: “si el servidor me dijo que algo falló, quiero que mi código lo trate como excepción”, lo que permite separar limpiamente la lógica de negocio del manejo de errores.

import time
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry


def build_session(
    retries: int = 3,
    backoff_factor: float = 0.5,
    status_forcelist: tuple = (500, 502, 503, 504),
) -> requests.Session:
    """
    Construye una Session con retry automático para errores transitorios.
    backoff_factor controla el tiempo entre intentos: 0, 0.5s, 1s, 2s...
    """
    retry_strategy = Retry(
        total=retries,
        backoff_factor=backoff_factor,
        status_forcelist=status_forcelist,
        # Evita reintentar en métodos que modifican estado
        allowed_methods=["GET", "HEAD", "OPTIONS"],
    )
    adapter = HTTPAdapter(max_retries=retry_strategy)
    session = requests.Session()
    # Aplica la estrategia tanto a http como a https
    session.mount("https://", adapter)
    session.mount("http://", adapter)
    return session


def fetch_repos(username: str, token: str) -> list[dict]:
    """
    Recupera repositorios públicos de un usuario de GitHub.
    Demuestra: Session, auth por Bearer token, params, timeout y manejo de respuesta.
    """
    session = build_session()

    # Headers comunes para toda la sesión — no repetir en cada llamada
    session.headers.update(
        {
            "Authorization": f"Bearer {token}",
            "Accept": "application/vnd.github+json",
            "X-GitHub-Api-Version": "2022-11-28",
        }
    )

    all_repos = []
    page = 1

    while True:
        response = session.get(
            f"https://api.github.com/users/{username}/repos",
            params={"per_page": 100, "page": page, "sort": "updated"},
            timeout=(3.05, 15),  # connect timeout, read timeout
        )

        # Lanza HTTPError para 4xx/5xx; sin esto, un 403 pasaría silencioso
        response.raise_for_status()

        repos = response.json()  # deserializa JSON; lanza JSONDecodeError si falla

        if not repos:
            break

        all_repos.extend(repos)
        page += 1

        # Respeta el rate limiting de la API si el servidor lo indica
        retry_after = response.headers.get("Retry-After")
        if retry_after:
            time.sleep(int(retry_after))

    return all_repos


def download_avatar(avatar_url: str, dest_path: str) -> None:
    """
    Descarga un archivo binario (imagen). Aquí usamos response.content,
    no .text ni .json(), porque el payload no es texto.
    """
    response = requests.get(avatar_url, timeout=(3.05, 30), stream=False)
    response.raise_for_status()

    with open(dest_path, "wb") as f:
        f.write(response.content)  # bytes crudos, sin decodificación


if __name__ == "__main__":
    import os

    TOKEN = os.environ["GITHUB_TOKEN"]
    USERNAME = "torvalds"

    try:
        repos = fetch_repos(USERNAME, TOKEN)
        print(f"Repositorios encontrados: {len(repos)}")
        for repo in repos[:5]:
            print(f"  {repo['name']} — ⭐ {repo['stargazers_count']}")

        # Descarga el avatar del primer repo con owner info
        if repos:
            avatar_url = repos[0]["owner"]["avatar_url"]
            download_avatar(avatar_url, f"{USERNAME}_avatar.jpg")
            print(f"Avatar guardado en {USERNAME}_avatar.jpg")

    except requests.exceptions.Timeout:
        print("El servidor tardó demasiado en responder")
    except requests.exceptions.HTTPError as e:
        print(f"Error HTTP {e.response.status_code}: {e.response.text[:200]}")
    except requests.exceptions.ConnectionError:
        print("No se pudo establecer conexión")

Qué está pasando aquí y por qué importa cada decisión

build_session y la estrategia de retry son lo primero porque establecen el contrato de resiliencia. Retry de urllib3 —la librería que requests usa internamente— implementa exponential backoff: si backoff_factor es 0.5, los intentos esperarán 0s, 0.5s, 1s, 2s entre cada fallo. El parámetro allowed_methods es deliberado: no querrías reintentar automáticamente un POST que podría duplicar una transacción. status_forcelist limita los reintentos a errores de servidor genuinamente transitorios —un 404 no va a desaparecer con un retry.

session.headers.update() define el contrato de autenticación una sola vez. El header Authorization: Bearer <token> es el patrón moderno para APIs que usan OAuth2 o JWTs. La alternativa auth=(user, pass) en cada llamada usa HTTP Basic Auth —válida para algunos servicios, pero el patrón Bearer es más común en APIs REST contemporáneas.

response.json() hace algo sutil pero importante: usa el encoding declarado en las cabeceras Content-Type de la respuesta para decodificar el cuerpo antes de parsear JSON. Si el servidor dice charset=utf-8, requests lo usa. Comparado con json.loads(response.text) —que es equivalente pero verbose—, .json() es la forma idiomática.

response.content vs response.text es una distinción de tipos: .content son bytes, .text es str (decodificado según el encoding detectado). Para imágenes, PDFs o cualquier payload binario, .content es la respuesta correcta. Usar .text en datos binarios no solo es semánticamente incorrecto —puede corromper los datos si el encoding introduce transformaciones.

La paginación con el while True y el break en lista vacía refleja cómo funcionan la mayoría de APIs que paginan por offsets: sigues pidiendo páginas hasta que el servidor devuelve una lista vacía o menos ítems que el per_page. Es más robusto que intentar parsear headers de paginación que no todos los servidores implementan igual.

Errores que debes conocer

Error: Olvidar timeout en llamadas a producción y confiar en que el servidor siempre responde.

# ❌ Wrong
response = requests.get("https://api.ejemplo.com/datos")

# ✅ Right
response = requests.get("https://api.ejemplo.com/datos", timeout=(3.05, 15))

Sin timeout, requests espera indefinidamente. En un servidor web, eso bloquea un worker. En un script batch, puede colgar el proceso horas.


Error: Ignorar el código de estado y acceder a .json() sin verificar que la respuesta es exitosa.

# ❌ Wrong
response = requests.get(url, timeout=10)
data = response.json()  # puede ser el JSON de un error 422, no los datos esperados

# ✅ Right
response = requests.get(url, timeout=10)
response.raise_for_status()  # lanza HTTPError si 4xx o 5xx
data = response.json()

Un 422 o 403 a menudo también devuelve JSON —el cuerpo del error del servidor. Sin raise_for_status(), tu código procesará ese JSON de error como si fueran datos válidos.


Error: Crear una Session nueva dentro de un bucle en lugar de reutilizarla.

# ❌ Wrong
for item_id in ids:
    session = requests.Session()  # nueva sesión, nueva conexión TCP cada vez
    session.get(f"https://api.ejemplo.com/items/{item_id}", timeout=10)

# ✅ Right
session = build_session()
for item_id in ids:
    session.get(f"https://api.ejemplo.com/items/{item_id}", timeout=10)

Cada Session nueva descarta el connection pool, forzando un nuevo handshake TCP —y TLS— en cada iteración. Con 100 requests al mismo host, la diferencia es significativa.


Control Block

154

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio