3. Reintentos, tiempos de espera y límites de peticiones
Nivel 8: Estado y ejecución fiable
Ya sabes reintentar sin duplicar. Falta saber cuándo reintentar, cuánto esperar entre intentos y cuándo rendirse. Un bucle de reintentos mal hecho es peor que no tener ninguno: convierte un fallo pasajero de un segundo en un bloqueo de quince minutos por exceso de peticiones.
No todos los errores merecen un reintento
La distinción es sencilla de enunciar: si el error describe una decisión del bróker, no lo reintentes; si describe un problema de transporte o de capacidad, reinténtalo.
- Reintentables. Fallo de conexión, tiempo de espera agotado, error 429 por límite de peticiones, y los 500, 502, 503 y 504 del servidor. Son problemas del canal o de la máquina de enfrente, y dentro de un rato pueden no estar.
- No reintentables. Orden rechazada, saldo o poder de compra insuficiente, símbolo inválido, mercado cerrado, credenciales caducadas, parámetros mal formados. Reintentar cinco veces un rechazo por saldo no te da saldo: te acerca al límite de peticiones y llena el registro de ruido que tapa el fallo de verdad.
import random
import time
import requests
REINTENTABLES = {408, 425, 429, 500, 502, 503, 504}
class ErrorPermanente(Exception):
pass
def clasificar(resp):
if resp.status_code < 400:
return "ok"
if resp.status_code in REINTENTABLES:
return "reintentable"
return "permanente"Ojo con dos matices. El primero: hay brókers que devuelven 200 con un cuerpo que dice "rechazada". El código HTTP no siempre coincide con el estado de negocio, así que clasifica mirando también el cuerpo cuando haga falta. El segundo: un 401 puede parecer permanente y no serlo, porque a veces es un desfase del reloj y no unas credenciales malas. La lista de arriba es un punto de partida sensato, no un dogma; ajústala a lo que devuelva tu bróker de verdad, comprobándolo contra su entorno de pruebas antes de fiarte.
Espera creciente, y con un poco de ruido
Reintentar de inmediato es golpear a alguien que ya está caído. La espera se duplica en cada intento (0,5 s, 1 s, 2 s, 4 s...) hasta un tope, y encima se le añade aleatoriedad. ¿Por qué el ruido? Porque si tienes cinco procesos, o cinco símbolos en paralelo, y todos fallan a la vez por la misma caída, todos reintentarían exactamente en el mismo instante y volverían a tumbar el servicio en cuanto se recupere. La aleatoriedad los desincroniza.
def con_reintentos(hacer_peticion, intentos=5, base=0.5, tope=30.0):
for intento in range(intentos):
resp = None
try:
resp = hacer_peticion()
except (requests.ConnectionError, requests.Timeout):
pass
else:
tipo = clasificar(resp)
if tipo == "ok":
return resp
if tipo == "permanente":
raise ErrorPermanente(str(resp.status_code) + " " + resp.text[:200])
espera = random.uniform(0, min(tope, base * (2 ** intento)))
if resp is not None and resp.status_code == 429:
espera = max(espera, float(resp.headers.get("Retry-After", 1)))
time.sleep(espera)
raise TimeoutError("agotados los reintentos")Y una condición de parada que no aparece en el código y sí en un sistema serio: si la señal ha caducado, no reintentes. Una orden pensada para la apertura no sirve veinte minutos después. Mejor abandonar y registrar que ejecutar tarde algo que el modelo nunca decidió.
Dos tiempos de espera, no uno
Ninguna llamada de red sin tiempo de espera explícito. El valor por omisión de casi todas las bibliotecas es "espera indefinidamente", y un hilo colgado para siempre en una petición es la forma más discreta de que un robot deje de operar sin que salte ninguna alarma.
En requests el parámetro admite dos números: el primero limita el establecimiento de la conexión y el segundo el hueco máximo entre bytes recibidos. Ese segundo matiz importa: no es un límite al tiempo total de la respuesta. Un servidor que envía datos muy despacio puede tenerte enganchado mucho más de lo que sugiere el número. Si necesitas un techo absoluto, hace falta además un plazo global que abarque todos los intentos.
resp = requests.post(url, headers=cabeceras, data=cuerpo, timeout=(3.05, 15))El límite de peticiones lo controlas tú, no el bróker
Esperar al 429 para frenar es como conducir mirando solo el golpe. Lo correcto es no pasarse nunca, con un contador propio del lado del cliente. Recuerda que el límite se aplica normalmente por clave de API o por dirección IP, así que abrir más conexiones o más procesos no lo multiplica: lo reparte.
class Cubo:
def __init__(self, por_segundo, capacidad):
self.ritmo = por_segundo
self.capacidad = capacidad
self.fichas = capacidad
self.ultimo = time.monotonic()
def pedir(self):
ahora = time.monotonic()
self.fichas = min(self.capacidad,
self.fichas + (ahora - self.ultimo) * self.ritmo)
self.ultimo = ahora
if self.fichas < 1:
time.sleep((1 - self.fichas) / self.ritmo)
self.fichas = 1
self.fichas -= 1Reparte además el presupuesto: las consultas de datos son sacrificables, el envío y la consulta de órdenes no. Si algo tiene que esperar, que espere el gráfico, nunca la orden. En la práctica esto significa dos cubos con ritmos distintos, o un cubo único con una cola de prioridad delante, para que un barrido de precios de cien símbolos no deje sin oxígeno a la única petición que mueve dinero.
Y hay una interacción con el módulo anterior que conviene subrayar: el reintento solo puede aplicarse al envío de órdenes si esas órdenes viajan con su identificador determinista. Sin él, el bucle de reintentos de este módulo se convierte en una máquina de duplicar posiciones muy eficiente. Los dos mecanismos son inseparables: la espera creciente decide cuándo volver a intentarlo y la idempotencia decide si volver a intentarlo es seguro.
Escenario trabajado: la tormenta de las 9:30
Escenario construido para el curso. Un robot sigue 40 símbolos y en la apertura pide para cada uno el último precio, las velas del día y las posiciones: unas 120 peticiones en el mismo segundo contra un límite de 200 por minuto. El bróker empieza a devolver 429. El bucle de reintentos, sin ruido y sin mirar la cabecera Retry-After, relanza las 120 a la vez tras un segundo, y otra vez, y otra. En tres minutos ha gastado más de 1.000 peticiones, el bróker aplica un bloqueo temporal y, cuando por fin llega la señal buena, la orden no se puede mandar. Nadie perdió dinero por una mala predicción: se perdió por un bucle mal escrito.