Lección 1.4.1 · 60 min

Descarga por REST y websockets: paginación, límites y reintentos

Bajar datos de una API parece una línea de código hasta que la API corta a la página doce. Esta lección construye un servidor local que se comporta como una API de mercado real —paginación, límites, errores aleatorios, un stream que pierde mensajes— y un cliente que sobrevive a todo ello.

Fig. — Backoff exponencial con full jitter: techo, esperas muestreadas y media por intento (base 0,5 s, tope 30 s)
01/08
Módulo
1.4 · Ingeniería de datos financieros
Duración
60 min
Lecciones del nivel 1
  1. 1.1 · Cómo funcionan los mercados

    1. 1.1.1 Participantes y centros de negociación
    2. 1.1.2 Sesiones, subastas, festivos y calendarios
    3. 1.1.3 Libro de órdenes: bid, ask, spread y profundidad
    4. 1.1.4 Tipos de órdenes
    5. 1.1.5 Costes, liquidación, apalancamiento y margen
  2. 1.2 · Clases de activos

    1. 1.2.1 Acciones: splits, dividendos y eventos corporativos
    2. 1.2.2 ETFs: réplica, tracking error, apalancados y UCITS
    3. 1.2.3 Futuros: especificaciones, margen, roll y contango
    4. 1.2.4 Forex: pares, pips, lotes, rollover y CFD
    5. 1.2.5 Opciones: call, put, valor intrínseco y valor temporal
    6. 1.2.6 Cripto: spot, perpetuos, funding y custodia
    7. 1.2.7 Renta fija y tipos de interés; CFDs y productos apalancados
  3. 1.3 · Fuentes de datos de mercado

    1. 1.3.1 Tipos de datos de mercado: EOD, barras, ticks, libro y referencia
    2. 1.3.2 Fuentes de datos gratuitas
    3. 1.3.3 APIs de datos retail y prosumer
    4. 1.3.4 Proveedores de datos profesionales
    5. 1.3.5 Datos de cripto y on-chain
    6. 1.3.6 Brókeres como fuente de datos
    7. 1.3.7 Licencias y términos de uso de datos
    8. 1.3.8 Cómo elegir proveedor de datos
  4. 1.4 · Ingeniería de datos financieros

    1. 1.4.1 Descarga por REST y websockets: paginación, límites y reintentos
    2. 1.4.2 Limpieza de datos: faltantes, ticks erróneos, huecos y zonas horarias
    3. 1.4.3 Ajustes por splits y dividendos: precios ajustados y sin ajustar
    4. 1.4.4 Universos sin sesgo de supervivencia: deslistados, fusiones y cambios de símbolo
    5. 1.4.5 Construcción de barras: tiempo, ticks, volumen, dólar y desequilibrio
    6. 1.4.6 Series continuas de futuros: reglas de roll, ajuste por diferencia y ajuste por ratio
    7. 1.4.7 Almacenamiento: CSV, Parquet, particionado, DuckDB, TimescaleDB y ArcticDB
    8. 1.4.8 Pipeline reproducible: descarga, validación, actualización incremental y tests de calidad
  5. 1.5 · Datos fundamentales y macro

    1. 1.5.1 Estados financieros, ratios, XBRL y SEC EDGAR
    2. 1.5.2 Datos macro, calendarios económicos, estimaciones y transcripciones
  6. 1.6 · Análisis exploratorio financiero

    1. 1.6.1 Rentabilidades, volatilidad, correlaciones móviles y drawdown
    2. 1.6.2 Hechos estilizados: colas gruesas, agrupamiento, autocorrelación y apalancamiento
    3. 1.6.3 Estacionalidad: día de la semana, mes, cambio de mes y sesiones intradía
  7. 1.7 · Análisis técnico programado

    1. 1.7.1 Indicadores técnicos en Python
    2. 1.7.2 Implementación propia frente a TA-Lib: validar resultados
    3. 1.7.3 Patrones de velas y soportes y resistencias
    4. 1.7.4 Multi-timeframe sin anticipación

Dos maneras de pedir datos

Casi todos los proveedores de la lección 1.3 ofrecen sus datos de dos formas. Por REST, el cliente pide un tramo («barras de 5 minutos de NOVA de junio») y el servidor responde con un documento JSON; si el tramo es grande, lo reparte en páginas. Por streaming (websockets, en la mayoría de las APIs retail), el cliente abre una conexión que se queda abierta y el servidor le empuja cada operación o cada cotización en cuanto ocurre.

GET páginaJSON + cursorsuscripciónmensaje seq nlatidoClienteAPI RESTStream
Fuente Mermaid
flowchart LR
  C[Cliente] -- GET página --> A[API REST]
  A -- JSON + cursor --> C
  C -- suscripción --> W[Stream]
  W -- mensaje seq n --> C
  W -- latido --> C
RESTStreaming (websocket)
Para quéHistórico, rellenar huecos, datos de referenciaTiempo real: operaciones, cotizaciones, libro
Quién iniciaEl cliente, petición a peticiónEl servidor, mensaje a mensaje
UnidadPágina de N filas con un cursor a la siguienteMensaje con número de secuencia
Fallo típico429 por exceso de peticiones, 5xx, cortes a mitadMensajes perdidos, conexión muerta sin aviso
RecuperaciónReintentar la misma petición con esperaReconectar y reanudar desde el último mensaje bueno

Las dos se usan juntas. Un sistema en vivo arranca con REST (el histórico necesario para calcular sus indicadores), se engancha al stream y, cada vez que detecta un hueco en el stream, vuelve a REST para rellenarlo.

Todo lo que sigue se ejecuta de verdad. En lugar de llamar a un proveedor real (necesitaría red, una clave y dependería de su humor ese día), la lección levanta un servidor local en un hilo que imita a una API de mercado: pagina, limita las peticiones y falla al azar. El cliente usa solo la biblioteca estándar (urllib); al final están las versiones con requests y websockets.

Una API de mercado en un hilo

El servidor sirve las barras de 5 minutos de NOVA (nova_barras_5min.parquet). Para que la lección sea reproducible y no espere de verdad, cliente y servidor comparten un reloj simulado: cada petición «tarda» 40 ms y cada espera del cliente adelanta ese reloj en lugar de dormir. Es la misma técnica que se usa para probar código con tiempos (inyectar el reloj); en producción, dormir sería time.sleep y cada lado mediría su propio tiempo.

IN [01]python
import base64, json, random, threading
import pandas as pd

barras = pd.read_parquet("nova_barras_5min.parquet")

class Reloj:
    """Reloj simulado que comparten cliente y servidor."""
    def __init__(self):
        self.t = 0.0
        self._cerrojo = threading.Lock()
    def ahora(self):
        return self.t
    def dormir(self, segundos):
        with self._cerrojo:
            self.t += segundos

def cursor_de(ts):   # opaco para el cliente: no debe construirlo
    return base64.urlsafe_b64encode(ts.isoformat().encode()).decode()

def ts_de(cursor):
    return pd.Timestamp(base64.urlsafe_b64decode(cursor).decode())

reloj = Reloj()
print(len(barras), "barras de", barras.ts.min().date(),
      "a", barras.ts.max().date())
OUT [01]Reloj simulado y reglas de la API
38940 barras de 2024-01-02 a 2025-12-31

La lógica de la API va en una clase aparte, sin nada de HTTP, para poder leerla de un vistazo. Tiene las tres reglas que tienen casi todas las APIs reales:

  • Límite de peticiones. 10 por ventana de 1 s. La undécima recibe 429 Too Many Requests con una cabecera Retry-After que dice cuántos segundos esperar. Cada respuesta buena trae X-RateLimit-Remaining, las que quedan.
  • Fallos transitorios. Un 8 % de las peticiones recibe 503 Service Unavailable, con un generador con semilla.
  • Paginación por cursor. Como mucho limite filas por respuesta y un campo siguiente con el cursor de la página siguiente (None en la última). Un limite mayor de 500 es un error del cliente: 400 Bad Request.
IN [02]python
class ApiMercado:
    POR_VENTANA = 10     # peticiones por ventana de 1 s
    LATENCIA = 0.04      # segundos simulados por petición

    def __init__(self, reloj, prob_fallo=0.08, semilla=7):
        self.reloj, self.prob_fallo = reloj, prob_fallo
        self.azar = random.Random(semilla)
        self.ventana, self.usadas = 0.0, 0
        self.registro = []           # (instante, estado)

    def responder(self, p):
        self.reloj.dormir(self.LATENCIA)
        ahora = self.reloj.ahora()
        if ahora - self.ventana >= 1.0:          # ventana nueva
            self.ventana, self.usadas = ahora, 0
        if self.usadas >= self.POR_VENTANA:
            falta = 1.0 - (ahora - self.ventana)
            cab = {"Retry-After": str(max(1, round(falta)))}
            return self._fin(429, {"error": "demasiadas"}, cab)
        self.usadas += 1
        if self.azar.random() < self.prob_fallo:
            return self._fin(503, {"error": "no disponible"})
        limite = int(p.get("limite", 100))
        if limite > 500:
            return self._fin(400, {"error": "limite máximo 500"})
        hasta = pd.Timestamp(p["hasta"], tz="UTC")
        if "cursor" in p:                        # después del cursor
            sel = barras[barras.ts > ts_de(p["cursor"])]
        else:
            sel = barras[barras.ts >= pd.Timestamp(p["desde"],
                                                   tz="UTC")]
        sel = sel[sel.ts < hasta]
        pag = sel.head(limite)
        sig = cursor_de(pag.ts.iloc[-1]) if len(sel) > limite else None
        datos = [{"t": r.ts.strftime("%Y-%m-%dT%H:%M:%SZ"),
                  "o": r.apertura, "h": r.maximo, "l": r.minimo,
                  "c": r.cierre, "v": r.volumen}
                 for r in pag.itertuples()]
        cab = {"X-RateLimit-Remaining":
               str(self.POR_VENTANA - self.usadas)}
        return self._fin(200, {"datos": datos, "siguiente": sig}, cab)

    def _fin(self, estado, cuerpo, cab=None):
        self.registro.append((round(self.reloj.ahora(), 2), estado))
        return estado, cab or {}, cuerpo

Ahora la parte HTTP: un ThreadingHTTPServer de la biblioteca estándar, en el puerto que el sistema tenga libre y en un hilo de fondo (hilos y asyncio: lección 0.4.6). El manejador traduce cada GET a una llamada a responder y devuelve el JSON con su código y sus cabeceras. El número de puerto cambia en cada ejecución, así que la celda no lo imprime.

IN [03]python
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qsl, urlsplit

api = ApiMercado(reloj)

class Manejador(BaseHTTPRequestHandler):
    def do_GET(self):
        url = urlsplit(self.path)
        if url.path == "/v1/barras/NOVA":
            estado, cab, cuerpo = api.responder(
                dict(parse_qsl(url.query)))
        else:
            estado, cab, cuerpo = 404, {}, {"error": "no existe"}
        datos = json.dumps(cuerpo).encode()
        self.send_response(estado)
        self.send_header("Content-Type", "application/json")
        for k, v in cab.items():
            self.send_header(k, v)
        self.send_header("Content-Length", str(len(datos)))
        self.end_headers()
        self.wfile.write(datos)

    def log_message(self, *args):   # sin una línea por petición
        pass

servidor = ThreadingHTTPServer(("127.0.0.1", 0), Manejador)
threading.Thread(target=servidor.serve_forever, daemon=True).start()
BASE = f"http://127.0.0.1:{servidor.server_port}"
print("Servidor en marcha:", servidor.server_port > 0)
OUT [03]Servidor HTTP local en un hilo
Servidor en marcha: True

Con el servidor en marcha falta el cliente: una función de diez líneas con urllib.request. Un detalle de urllib: lanza HTTPError con cualquier código 4xx o 5xx. Para tratar todos los códigos igual, la función captura esa excepción y devuelve el código, las cabeceras y el cuerpo, sea cual sea el resultado. Los errores de red (conexión rechazada, tiempo agotado) sí se dejan pasar: son otra cosa y se tratan más abajo.

IN [04]python
import urllib.error, urllib.request
from urllib.parse import urlencode

def pedir(ruta, **params):
    url = f"{BASE}{ruta}?{urlencode(params)}"
    try:
        with urllib.request.urlopen(url, timeout=5) as r:
            return r.status, r.headers, json.load(r)
    except urllib.error.HTTPError as e:   # 4xx y 5xx
        return e.code, e.headers, json.load(e)

JUNIO = dict(desde="2025-06-02", hasta="2025-07-01", limite=100)
estado, cab, cuerpo = pedir("/v1/barras/NOVA", **JUNIO)
print("Estado:", estado)
print("Quedan:", cab["X-RateLimit-Remaining"], "peticiones")
print("Filas:", len(cuerpo["datos"]))
print("Primera:", cuerpo["datos"][0])
print("Cursor:", cuerpo["siguiente"])
OUT [04]Cliente mínimo con urllib
Estado: 200
Quedan: 9 peticiones
Filas: 100
Primera: {'t': '2025-06-02T13:30:00Z', 'o': 53.35, 'h': 53.48, 'l': 53.28, 'c': 53.46, 'v': 285081}
Cursor: MjAyNS0wNi0wM1QxNToxNTowMCswMDowMA==

Cien barras y un cursor. El cursor es una cadena opaca: en este servidor codifica la hora de la última barra servida, pero el cliente no debe interpretarlo ni fabricarlo, solo devolverlo. Los proveedores cambian su formato sin avisar. Una petición con un parámetro inválido muestra el otro tipo de error, el que no tiene arreglo esperando:

IN [05]python
estado, _, cuerpo = pedir("/v1/barras/NOVA", desde="2025-06-02",
                          hasta="2025-07-01", limite=1000)
print(estado, cuerpo)
OUT [05]Un error del cliente no se reintenta
400 {'error': 'limite máximo 500'}

Paginación: por qué un cursor y no un desplazamiento

Hay dos maneras habituales de paginar. Por desplazamiento (offset=200&limit=100: «dame las filas 200 a 299») o por cursor («dame las 100 siguientes a esta clave»). Con datos de mercado el desplazamiento es frágil: si el proveedor inserta o corrige barras mientras se descarga (pasa a menudo con el día en curso o tras una corrección de la cinta), las posiciones se mueven y la página siguiente repite o se salta filas sin ningún error visible. El cursor apunta a una clave (aquí, la hora de la última barra) y no depende de las posiciones.

El bucle ingenuo pide páginas hasta que siguiente sea None y da por buena cualquier respuesta. Contra esta API dura poco:

IN [06]python
def descargar_ingenuo(**params):
    filas, cursor, n = [], None, 0
    while True:
        n += 1
        extra = {"cursor": cursor} if cursor else {}
        estado, _, cuerpo = pedir("/v1/barras/NOVA",
                                  **params, **extra)
        if estado != 200:
            raise RuntimeError(f"HTTP {estado} en la página {n}")
        filas += cuerpo["datos"]
        cursor = cuerpo["siguiente"]
        if cursor is None:
            return filas

descargar_ingenuo(**JUNIO)
OUT [06]Bucle ingenuo: sin reintentos
RuntimeError: HTTP 503 en la página 2

Se rompe en la segunda página con un 503 que, repetido un instante después, habría funcionado. Peor que romperse sería lo contrario: un bucle que ignorase el código de estado y siguiera sin la página, con un hueco de cien barras en mitad de junio que nadie notaría hasta que un indicador saliese raro.

Qué se reintenta y qué no

Los códigos de estado HTTP dicen de quién es el problema. Los 4xx son errores del cliente: repetir la misma petición dará el mismo error. La excepción es el 429: la petición es correcta, pero llega demasiado pronto. Los 5xx son del servidor y casi siempre transitorios. Los errores de red (tiempo agotado, conexión cortada) no tienen código, pero también son transitorios.

RespuestaSignificadoQué hace el cliente
200CorrectoGuarda la página y sigue con el cursor
400, 422Parámetros inválidosFalla en el acto: hay que corregir el código
401, 403Sin credenciales o sin permiso (plan, licencia)Falla en el acto: reintentar no da permisos
404El recurso no existe (símbolo mal escrito, ruta)Falla en el acto
429Demasiadas peticionesEspera lo que diga Retry-After y repite
500, 502, 503, 504Fallo o sobrecarga del servidorEspera con backoff y repite
Tiempo agotado, conexión cortadaRedEspera con backoff y repite

El diagrama recoge el bucle completo. Lo importante está en las flechas que vuelven: la petición que se repite es la misma, con el mismo cursor; nunca se salta a la página siguiente.

200síno4295xx o red4xxPedir página¿Estado?Guardar página¿Hay cursor?FinEspera Retry-AfterEsperar backoffFallo definitivo
Fuente Mermaid
flowchart TB
  P[Pedir página] --> E{¿Estado?}
  E -- 200 --> G[Guardar página]
  G --> S{¿Hay cursor?}
  S -- sí --> P
  S -- no --> F[Fin]
  E -- 429 --> R[Espera Retry-After]
  E -- 5xx o red --> B[Esperar backoff]
  R --> P
  B --> P
  E -- 4xx --> X[Fallo definitivo]

Backoff exponencial, jitter y ritmo del cliente

Esperar siempre lo mismo entre reintentos no basta: si el servidor está sobrecargado, todos los clientes que fallaron vuelven a la vez y lo tumban otra vez. El backoff exponencial dobla la espera en cada intento, con un tope para no esperar horas. El jitter añade azar. Marc Brooker comparó varias formas de hacerlo en el blog de arquitectura de AWS; la más sencilla, la full jitter, que está entre las que menos peticiones totales generan en su simulación, espera un tiempo uniforme entre cero y el techo exponencial:

esperan∼U(0, min⁡(tope, base⋅2n)),n=0,1,2,…\text{espera}_n \sim \mathcal{U}\big(0,\ \min(\text{tope},\ \text{base}\cdot 2^{n})\big), \qquad n = 0, 1, 2, \dots
IN [07]python
def espera_backoff(n, base=0.5, tope=30.0, azar=random):
    return azar.uniform(0, min(tope, base * 2 ** n))

azar = random.Random(42)
filas = []
for n in range(9):
    techo = min(30.0, 0.5 * 2 ** n)
    muestras = [espera_backoff(n, azar=azar) for _ in range(200)]
    filas.append({"intento": n + 1, "techo_s": techo,
                  "media_s": sum(muestras) / len(muestras),
                  "max_s": max(muestras)})
tabla_backoff = pd.DataFrame(filas).set_index("intento").round(2)
tabla_backoff
OUT [07]Esperas del backoff por intento
         techo_s  media_s  max_s
intento
1            0.5     0.24   0.50
2            1.0     0.52   1.00
3            2.0     0.99   2.00
4            4.0     2.06   4.00
5            8.0     4.34   7.99
6           16.0     7.46  15.91
7           30.0    14.99  29.84
8           30.0    15.49  29.99
9           30.0    15.40  29.72

La figura de la cabecera dibuja la tabla: el techo de cada intento, cinco esperas sorteadas y la media. El techo se dobla hasta chocar con el tope de 30 s en el séptimo intento; a partir de ahí se queda plano. La espera media sale muy cerca de la mitad del techo (0,24 s con techo 0,5; 14,99 s con techo 30), porque esa es la media de una uniforme entre 0 y el techo. Con seis intentos hay cinco esperas: el peor caso suma 15,5 s (0,5 + 1 + 2 + 4 + 8) y el caso medio, la mitad. Es el orden de magnitud razonable para un script de descarga nocturno; un sistema en vivo necesita topes mucho más cortos y, sobre todo, saber qué hacer mientras no tiene datos.

La función con reintentos combina todo lo anterior: reintenta solo lo reintentable, respeta Retry-After cuando llega (si el servidor dice cuánto esperar, se le hace caso) y se rinde tras un número de intentos. El reloj y el generador de azar entran como argumentos: así el mismo código sirve en producción (time.sleep, azar real) y en una prueba reproducible.

IN [08]python
REINTENTABLES = {429, 500, 502, 503, 504}

def pedir_con_reintentos(ruta, intentos=6, dormir=reloj.dormir,
                         azar=random.Random(1), **params):
    for n in range(intentos):
        try:
            estado, cab, cuerpo = pedir(ruta, **params)
        except (urllib.error.URLError, TimeoutError,
                ConnectionError):
            estado, cab, cuerpo = None, {}, None   # red
        if estado == 200:
            return cuerpo
        if estado is not None and estado not in REINTENTABLES:
            raise RuntimeError(f"HTTP {estado}: {cuerpo['error']}")
        if n == intentos - 1:
            break                    # sin espera tras el último
        espera = espera_backoff(n, azar=azar)
        if cab.get("Retry-After"):
            espera = max(espera, float(cab["Retry-After"]))
        dormir(espera)
    raise RuntimeError(f"sin respuesta tras {intentos} intentos")

La descarga completa es el bucle ingenuo con la función nueva. Antes de empezar se anota dónde está el registro del servidor, para contar después cuántas peticiones de cada tipo hicieron falta:

IN [09]python
from collections import Counter

def descargar(**params):
    filas, cursor = [], None
    while True:
        extra = {"cursor": cursor} if cursor else {}
        pag = pedir_con_reintentos("/v1/barras/NOVA",
                                   **params, **extra)
        filas += pag["datos"]
        cursor = pag["siguiente"]
        if cursor is None:
            return filas

inicio, t0 = len(api.registro), reloj.ahora()
filas = descargar(**JUNIO)
estados = Counter(e for _, e in api.registro[inicio:])
print("Filas:", len(filas), "· páginas:", -(-len(filas) // 100))
print("Peticiones por estado:", dict(sorted(estados.items())))
print(f"Tiempo simulado: {reloj.ahora() - t0:.1f} s")

# lo descargado tiene que ser exactamente lo que hay en el origen
df = pd.DataFrame(filas)
df["t"] = pd.to_datetime(df["t"], utc=True)
origen = barras[(barras.ts >= "2025-06-02")
                & (barras.ts < "2025-07-01")]
print("Iguales al origen:", len(df) == len(origen)
      and df["t"].is_unique
      and (df["c"].to_numpy() == origen["cierre"].to_numpy()).all())
OUT [09]Descarga completa de junio
Filas: 1560 · páginas: 16
Peticiones por estado: {200: 16, 429: 2, 503: 4}
Tiempo simulado: 3.9 s
Iguales al origen: True
Fig. — Peticiones de la descarga de junio en el reloj simulado: sin limitador del cliente y con él

Las 16 páginas han necesitado 22 peticiones: 2 respuestas 429 y 4 respuestas 503, cada una repetida. En la fila de arriba de la figura, cada marca es una petición en el reloj simulado. Los 429 llegan cuando el cliente agota las 10 peticiones de la ventana, porque pide más deprisa de lo que la API permite, y cada uno cuesta un segundo entero de Retry-After: de los 3,9 s simulados, 2 se van en esas dos esperas. La última línea es la comprobación que importa: mismas filas que en el origen, sin horas repetidas y con los mismos cierres.

Los 429 no son gratis: cada uno es una petición perdida y, en muchas APIs, insistir con ellas acaba en un bloqueo temporal de la clave. Si se conoce el límite (viene en la documentación del plan o en cabeceras como X-RateLimit-Remaining), lo correcto es no llegar a él: un limitador del lado del cliente que espacie las peticiones. El más simple garantiza un intervalo mínimo entre dos peticiones; con 10 por segundo y algo de margen, 9 por segundo, una cada 111 ms.

IN [10]python
class Limitador:
    def __init__(self, por_segundo, reloj):
        self.intervalo, self.reloj = 1.0 / por_segundo, reloj
        self.ultima = -1e9
    def esperar(self):
        falta = self.ultima + self.intervalo - self.reloj.ahora()
        if falta > 0:
            self.reloj.dormir(falta)
        self.ultima = self.reloj.ahora()

limitador = Limitador(9, reloj)
pedir_sin_limite = pedir

def pedir(ruta, **params):           # misma firma, ahora espaciada
    limitador.esperar()
    return pedir_sin_limite(ruta, **params)

inicio, t0 = len(api.registro), reloj.ahora()
filas_lim = descargar(**JUNIO)
estados = Counter(e for _, e in api.registro[inicio:])
print("Filas:", len(filas_lim))
print("Peticiones por estado:", dict(sorted(estados.items())))
print(f"Tiempo simulado: {reloj.ahora() - t0:.1f} s")
pedir = pedir_sin_limite             # se deja como estaba
OUT [10]Limitador del cliente: un intervalo mínimo
Filas: 1560
Peticiones por estado: {200: 16, 503: 2}
Tiempo simulado: 2.9 s

Sin un solo 429: 18 peticiones en lugar de 22. Y, aunque cada petición espera su turno, la descarga termina antes, en 2,9 s simulados frente a 3,9, porque ya no paga el segundo de Retry-After de cada 429 (la fila de abajo de la figura). Los 503 siguen ahí, dos esta vez, porque no dependen del ritmo del cliente: el limitador evita los errores que provoca uno mismo y los reintentos cubren los del servidor. Hacen falta las dos cosas.

Una descarga que se puede cortar

Una descarga de años de barras de un minuto son miles de páginas y horas de reloj. En algún momento se cortará: un reinicio, la red, un error que se agota tras seis intentos. Repetirla entera es lento y, si los datos cuestan por petición (lección 1.3.4), caro. La solución es hacerla reanudable e idempotente: repetir un paso ya hecho no cambia el resultado.

  • Cada página se guarda en su propio archivo con un nombre fijo (p0007.json), de forma atómica: se escribe en un temporal y se renombra con os.replace, que no deja nunca un archivo a medias.
  • Después de la página se guarda el estado: número de página y cursor siguiente. Si el proceso muere entre las dos escrituras, al reanudar se vuelve a pedir la misma página y se sobrescribe el mismo archivo con el mismo contenido.
IN [11]python
import os
from pathlib import Path

def escribir_atomico(ruta, objeto):
    tmp = ruta.with_suffix(".tmp")
    tmp.write_text(json.dumps(objeto))
    os.replace(tmp, ruta)            # atómico en el mismo disco

def descargar_reanudable(carpeta, fallar_en=None, **params):
    carpeta = Path(carpeta)
    carpeta.mkdir(exist_ok=True)
    f_estado = carpeta / "estado.json"
    estado = (json.loads(f_estado.read_text()) if f_estado.exists()
              else {"pagina": 0, "cursor": None, "fin": False})
    while not estado["fin"]:
        if estado["pagina"] == fallar_en:
            raise ConnectionError("corte simulado de la red")
        extra = {"cursor": estado["cursor"]} if estado["cursor"] else {}
        pag = pedir_con_reintentos("/v1/barras/NOVA",
                                   **params, **extra)
        n = estado["pagina"] + 1
        escribir_atomico(carpeta / f"p{n:04d}.json", pag["datos"])
        estado = {"pagina": n, "cursor": pag["siguiente"],
                  "fin": pag["siguiente"] is None}
        escribir_atomico(f_estado, estado)
    return estado

La primera ejecución se corta a propósito después de la sexta página:

IN [12]python
descargar_reanudable("junio", fallar_en=6, **JUNIO)
OUT [12]Primera ejecución: corte en la página 7
ConnectionError: corte simulado de la red
IN [13]python
print("Páginas en disco:", len(list(Path("junio").glob("p*.json"))))
antes = len(api.registro)
print(descargar_reanudable("junio", **JUNIO))
print("Peticiones al reanudar:", len(api.registro) - antes)

antes = len(api.registro)
descargar_reanudable("junio", **JUNIO)   # ya estaba terminada
print("Peticiones en la tercera ejecución:",
      len(api.registro) - antes)

paginas = sorted(Path("junio").glob("p*.json"))
junio = pd.concat(pd.DataFrame(json.loads(p.read_text()))
                  for p in paginas)
print("Filas:", len(junio),
      "· repetidas:", junio["t"].duplicated().sum())
OUT [13]Reanudar y volver a ejecutar
Páginas en disco: 6
{'pagina': 16, 'cursor': None, 'fin': True}
Peticiones al reanudar: 12
Peticiones en la tercera ejecución: 0
Filas: 1560 · repetidas: 0

La segunda ejecución empieza en la página 7, sin repetir las seis que ya estaban: 12 peticiones para las 10 páginas que faltaban (dos 503 por el camino). La tercera no hace ni una petición. El conjunto final tiene las 1.560 barras sin duplicados. La misma idea, a escala de un data lake (una partición por día o por mes, y un registro de qué particiones están completas), es la base de la actualización incremental de la lección 1.4.8.

Streaming: secuencia, latidos y reconexión

Un stream no se pagina: llega. Sus fallos son distintos y más traicioneros, porque no dan error:

  • Mensajes perdidos. Un mensaje que no llega (reconexión del servidor, cola desbordada) no avisa. Por eso los feeds serios numeran cada mensaje: un salto en la secuencia delata la pérdida.
  • Conexión muerta. Un cable que se corta en medio no siempre cierra la conexión TCP: el cliente se queda esperando datos que no vendrán, y un mercado tranquilo parece idéntico a una conexión rota. Para distinguirlos, el servidor manda latidos (mensajes vacíos cada pocos segundos; el protocolo websocket tiene además tramas ping y pong propias). Si no llega nada, ni datos ni latidos, durante un plazo, la conexión está muerta.
suscritoseq n+1 o latidohueco en seqsilenciodesde última seqfinConectandoRecibiendoReconectando
Fuente Mermaid
stateDiagram-v2
  [*] --> Conectando
  Conectando --> Recibiendo: suscrito
  Recibiendo --> Recibiendo: seq n+1 o latido
  Recibiendo --> Reconectando: hueco en seq
  Recibiendo --> Reconectando: silencio
  Reconectando --> Conectando: desde última seq
  Recibiendo --> [*]: fin

El servidor de esta sección usa asyncio.start_server, un servidor TCP de la biblioteca estándar en el mismo bucle de eventos que el cliente. Los mensajes son líneas JSON (un websocket real añade un protocolo de tramas encima, pero la lógica de secuencia y latidos es la misma). Emite las 40 primeras operaciones de NOVA del 2 de junio, con un latido cada ocho, y falla de dos maneras: en la primera conexión se «pierde» el mensaje 17 y en la segunda se queda mudo después del 30 sin cerrar la conexión.

IN [14]python
import asyncio

ops = pd.read_parquet("nova_operaciones.parquet").head(40)
MENSAJES = [{"tipo": "op", "seq": i + 1, "precio": r.precio,
             "tamano": int(r.tamano)}
            for i, r in enumerate(ops.itertuples())]
conexiones = 0

async def atender(lector, escritor):
    global conexiones
    conexiones += 1
    desde = json.loads(await lector.readline())["desde"]
    salida = []
    for m in MENSAJES[desde - 1:]:
        if conexiones == 1 and m["seq"] == 17:
            continue                  # mensaje perdido
        if conexiones == 2 and m["seq"] == 31:
            break                     # se calla, sin "fin"
        salida.append(m)
        if m["seq"] % 8 == 0:
            salida.append({"tipo": "latido"})
    else:
        salida.append({"tipo": "fin"})
    escritor.write("".join(json.dumps(m) + "\n"
                           for m in salida).encode())
    await escritor.drain()
    if conexiones == 2:
        await mudo.wait()             # conexión abierta y muda
    escritor.close()

El cliente lleva la cuenta de la última secuencia buena. Con cada mensaje comprueba que sea la siguiente; si no, lo descarta, cierra y reconecta pidiendo desde la que falta. La lectura lleva un plazo (asyncio.wait_for, 50 ms aquí; en un feed real, unas veces el intervalo de latido): si vence, la conexión se da por muerta.

IN [15]python
async def consumir(puerto, plazo=0.05):
    recibidos, eventos, ultima, k = [], [], 0, 0
    while True:
        k += 1
        lector, escritor = await asyncio.open_connection(
            "127.0.0.1", puerto)
        escritor.write((json.dumps({"desde": ultima + 1})
                        + "\n").encode())
        await escritor.drain()
        eventos.append(f"conexión {k}: desde seq {ultima + 1}")
        try:
            while True:
                linea = await asyncio.wait_for(lector.readline(),
                                               plazo)
                m = json.loads(linea)
                if m["tipo"] == "fin":
                    escritor.close()
                    return recibidos, eventos
                if m["tipo"] == "latido":
                    continue
                if m["seq"] != ultima + 1:
                    eventos.append(f"  hueco: esperaba {ultima + 1}"
                                   f", llegó {m['seq']}")
                    break
                recibidos.append(m)
                ultima = m["seq"]
        except TimeoutError:
            eventos.append(f"  silencio tras seq {ultima}")
        escritor.close()

mudo = asyncio.Event()
srv = await asyncio.start_server(atender, "127.0.0.1", 0)
puerto = srv.sockets[0].getsockname()[1]
recibidos, eventos = await consumir(puerto)
mudo.set()                            # libera la conexión muda
srv.close()
await srv.wait_closed()
print("\n".join(eventos))
seqs = [m["seq"] for m in recibidos]
print("Recibidos:", len(seqs), "· completos:",
      seqs == list(range(1, 41)))
OUT [15]Cliente con detección de huecos y silencios
conexión 1: desde seq 1
  hueco: esperaba 17, llegó 18
conexión 2: desde seq 17
  silencio tras seq 30
conexión 3: desde seq 31
Recibidos: 40 · completos: True
Fig. — Los 40 mensajes del stream por conexión: un hueco en la secuencia, un silencio y dos reconexiones

Tres conexiones para 40 mensajes. En la primera, el cliente ve llegar el 18 cuando esperaba el 17, lo descarta y reconecta desde el 17. En la segunda, el servidor se calla después del 30; los latidos tampoco llegan, así que a los 50 ms el cliente decide que la conexión está muerta y abre otra desde el 31. El resultado son los 40 mensajes, en orden y sin repetir. Sin la comprobación de secuencia, el cliente habría seguido con el 17 de menos y una barra de 5 minutos con un volumen falso; sin el plazo, se habría quedado esperando para siempre.

Muchos feeds no permiten reanudar desde una secuencia arbitraria. Entonces la recuperación es por REST: se pide el tramo que falta (las operaciones entre dos horas) o una foto completa del estado (el libro de órdenes entero) y se sigue con el stream a partir de ella. Es el patrón de la lección 1.1.3 para reconstruir un libro: foto más mensajes, y foto nueva si los mensajes tienen un hueco.

Las bibliotecas de verdad

En un proyecto real no hace falta escribir el bucle de reintentos a mano. Con requests, el reintento con backoff lo hace urllib3 por debajo, configurado con un objeto Retry montado en la sesión. Respeta Retry-After y solo reintenta los códigos de la lista. Su jitter es otra variante: suma a la espera exponencial un azar entre 0 y backoff_jitter, en lugar de sortear la espera entera. Este bloque no se ejecuta en la lección (requests no está en el entorno del curso y la URL es de ejemplo):

IN [16]descarga_requests.pypython
import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry

reintentos = Retry(
    total=6,
    backoff_factor=0.5,           # esperas de 0, 1, 2, 4… s
    backoff_max=30,               # tope
    backoff_jitter=0.5,           # + U(0; 0,5) s en cada espera
    status_forcelist=[429, 500, 502, 503, 504],
    allowed_methods=["GET"],      # solo métodos seguros
    respect_retry_after_header=True,
)
sesion = requests.Session()
sesion.mount("https://", HTTPAdapter(max_retries=reintentos))
sesion.headers["Authorization"] = "Bearer <tu clave>"

url = "https://api.proveedor.example/v1/barras/NOVA"
params = {"desde": "2025-06-02", "hasta": "2025-07-01"}
while url:
    r = sesion.get(url, params=params, timeout=10)
    r.raise_for_status()          # 4xx: fallo definitivo
    pagina = r.json()
    ...                           # guardar la página
    url, params = pagina.get("next_url"), None

Muchos proveedores devuelven, como en la última línea, una URL completa de la página siguiente (next_url) en lugar de un cursor suelto; la lógica no cambia. Para el streaming, la biblioteca websockets gestiona las tramas ping y pong (ping_interval, ping_timeout) y, usada como iterador, reconecta sola con backoff. La comprobación de secuencia sigue siendo cosa tuya:

IN [17]stream_websockets.pypython
import asyncio, json
from websockets.asyncio.client import connect

async def stream(url):
    ultima = 0
    async for ws in connect(url, ping_interval=20, ping_timeout=20):
        await ws.send(json.dumps({"suscribir": "NOVA",
                                  "desde": ultima + 1}))
        async for texto in ws:
            m = json.loads(texto)
            if m["seq"] != ultima + 1:
                break             # hueco: reconectar o pedir por REST
            ultima = m["seq"]
            ...                   # procesar el mensaje

asyncio.run(stream("wss://stream.proveedor.example/v1"))

Ejercicios

  1. Cambia prob_fallo a 0,3 en una ApiMercado nueva (con un servidor nuevo o sustituyendo api) y repite la descarga de junio. ¿Cuántas peticiones hacen falta ahora y cuánto tiempo simulado? ¿Llega a agotarse algún reintento?
Solución
IN [18]python
api = ApiMercado(reloj, prob_fallo=0.3, semilla=3)
t0 = reloj.ahora()
filas_30 = descargar(**JUNIO)
estados = Counter(e for _, e in api.registro)
print("Filas:", len(filas_30))
print("Peticiones por estado:", dict(sorted(estados.items())))
print(f"Tiempo simulado: {reloj.ahora() - t0:.1f} s")
OUT [18]API con un 30 % de fallos
Filas: 1560
Peticiones por estado: {200: 16, 503: 7}
Tiempo simulado: 3.1 s

El manejador del servidor busca api en el espacio de nombres de la lección cada vez que atiende una petición, así que basta con sustituirla. Con un 30 % de fallos la descarga termina igual, con las 1.560 filas, y ningún reintento se agota: 23 peticiones, 7 de ellas 503. Ningún 429, porque las esperas del backoff frenan al cliente lo bastante para no llegar al límite de la ventana. La probabilidad de agotar seis intentos seguidos es 0,3⁶ ≈ 0,07 % por página: en 16 páginas es improbable; en 10.000 páginas pasaría unas siete veces. Por eso una descarga larga tiene que ser además reanudable.

  1. Escribe una versión de espera_backoff con equal jitter: la mitad del techo fija y la otra mitad al azar (techo / 2 + U(0, techo / 2)). Compara su espera media y su espera mínima con las de la full jitter en el intento 5.
Solución
IN [19]python
def espera_equal(n, base=0.5, tope=30.0, azar=random):
    techo = min(tope, base * 2 ** n)
    return techo / 2 + azar.uniform(0, techo / 2)

azar = random.Random(3)
full = [espera_backoff(4, azar=azar) for _ in range(10_000)]
equal = [espera_equal(4, azar=azar) for _ in range(10_000)]
for nombre, x in [("full", full), ("equal", equal)]:
    print(f"{nombre:>5}: media {sum(x) / len(x):.2f} s, "
          f"mínima {min(x):.2f} s, máxima {max(x):.2f} s")
OUT [19]Full jitter frente a equal jitter
 full: media 4.00 s, mínima 0.00 s, máxima 8.00 s
equal: media 6.00 s, mínima 4.00 s, máxima 8.00 s

En el quinto intento (n = 4) el techo es 8 s. La full jitter espera de media 4 s y puede reintentar casi en el acto; la equal jitter espera de media 6 s y nunca menos de 4. La primera dispersa más los reintentos y termina antes de media; la segunda garantiza una pausa mínima, útil si el servidor castiga los reintentos muy seguidos. En la simulación de Brooker, la equal jitter hacía algo más de trabajo que la full jitter y tardaba bastante más.

  1. Un proveedor pagina por desplazamiento (offset) y, mientras descargas el día en curso, añade barras nuevas al final. ¿Se repiten o se pierden filas? ¿Y si corrige (borra y vuelve a insertar) una barra de una página que ya has bajado? Propón cómo detectarlo después de la descarga.
Solución

Si las barras nuevas se añaden al final, las posiciones de las ya existentes no cambian: no se repite ni se pierde nada (solo faltan las añadidas después de la última página, que entrarán en la siguiente actualización). Si se borra y se reinserta una barra de una página ya bajada, y la reinserción la deja al final o en otra posición, todo lo que había detrás se desplaza: la página siguiente se salta una fila o repite otra. Para detectarlo, después de la descarga se comprueba que la clave (la hora de la barra) sea única y que no falten horas esperadas según el calendario de la sesión (lección 1.4.2), y se compara el número de filas con el total que declare la API, si lo da.

Respuesta plegada en cada pregunta

Autoevaluación

  1. Una API devuelve 503 Service Unavailable en la página 7 de 16. ¿Qué debe hacer el cliente?

    • AAbortar la descarga: los datos están corruptos
    • BEsperar un tiempo creciente con algo de azar y repetir la misma petición, con el mismo cursor
    • CSaltar a la página 8 para no perder tiempo
    • DRepetir la petición inmediatamente en bucle hasta que funcione
    Ver respuesta

    Respuesta B. Esperar un tiempo creciente con algo de azar y repetir la misma petición, con el mismo cursor

    Un 5xx es un fallo transitorio del servidor: la misma petición puede funcionar después. Se repite con el mismo cursor (no se salta nada) tras una espera con backoff exponencial y jitter. Reintentar sin esperar empeora la sobrecarga que probablemente causó el error.

  2. ¿Por qué se añade jitter (azar) a la espera del backoff exponencial?

    • APara que el código parezca menos determinista
    • BPorque muchos clientes que fallaron a la vez reintentarían a la vez y volverían a saturar el servidor; el azar reparte los reintentos en el tiempo
    • CPorque time.sleep no admite números enteros
    • DPara que la espera media sea más larga
    Ver respuesta

    Respuesta B. Porque muchos clientes que fallaron a la vez reintentarían a la vez y volverían a saturar el servidor; el azar reparte los reintentos en el tiempo

    Sin jitter, todos los clientes que recibieron el error en el mismo instante esperan exactamente lo mismo y llegan juntos otra vez: una avalancha sincronizada. Con espera uniforme entre 0 y el tope exponencial, los reintentos se dispersan.

  3. Una descarga por desplazamiento (offset=200&limit=100) pide la página 3 justo después de que el proveedor inserte 5 barras corregidas al principio del rango. ¿Qué pasa?

    • ANada: el desplazamiento es estable
    • BLa página 3 empieza 5 filas antes de lo que debería y repite filas de la página 2
    • CEl servidor devuelve un 400
    • DSe pierden las 5 barras nuevas y nada más
    Ver respuesta

    Respuesta B. La página 3 empieza 5 filas antes de lo que debería y repite filas de la página 2

    El desplazamiento cuenta posiciones en un conjunto que ha cambiado: las 5 filas insertadas empujan todo hacia delante y la página siguiente repite las 5 últimas de la anterior (o se salta filas, si se borraron). El cursor apunta a la última clave vista y no depende de las posiciones.

  4. Un cliente de streaming recibe los mensajes con secuencia 15, 16 y 18. ¿Qué es lo correcto?

    • AProcesar el 18 y seguir
    • BDescartar el 18, marcar el hueco y recuperar desde el 17 (reconexión con reanudación o petición REST del tramo)
    • CEsperar a que el 17 llegue solo
    • DReordenar los mensajes por hora
    Ver respuesta

    Respuesta B. Descartar el 18, marcar el hueco y recuperar desde el 17 (reconexión con reanudación o petición REST del tramo)

    Un salto en la secuencia significa que se perdió un mensaje. Procesar el 18 deja un estado (libro, posición, barra) incorrecto sin saberlo. Lo seguro es recuperar el tramo que falta antes de seguir.

  5. La descarga reanudable guarda primero la página y después el archivo de estado con el cursor. Si el proceso muere entre las dos escrituras, ¿qué ocurre al reanudar?

    • ASe pierde una página
    • BSe vuelve a pedir la misma página y se sobrescribe el mismo archivo con el mismo contenido: no hay duplicados
    • CSe duplica la página
    • DLa descarga ya no se puede reanudar
    Ver respuesta

    Respuesta B. Se vuelve a pedir la misma página y se sobrescribe el mismo archivo con el mismo contenido: no hay duplicados

    El estado todavía apunta al cursor anterior, así que se pide otra vez la misma página. Como el archivo de cada página tiene un nombre fijo y se escribe de forma atómica, repetir el paso no cambia el resultado: es idempotente.

Ideas clave

  • Una API de datos pagina, limita y falla. El cliente tiene que contar con las tres cosas desde la primera línea.
  • Se reintentan 429, 5xx y errores de red; no se reintentan 400, 401, 403 ni 404. Si llega Retry-After, manda sobre el backoff.
  • Backoff exponencial con tope y jitter: espera uniforme entre 0 y min(tope, base · 2^n). Sin azar, los clientes reintentan sincronizados.
  • Paginar por cursor, guardar cada página con nombre fijo y escritura atómica, y el cursor después: así un corte a mitad se reanuda sin huecos ni duplicados.
  • En un stream, cada mensaje lleva secuencia y el servidor manda latidos: un salto de secuencia es un mensaje perdido y un silencio largo, una conexión muerta. Las dos cosas se resuelven reconectando desde el último mensaje bueno.

Recursos

Datos