El reintentador que no reintenta, el primer reintento que no espera nada, y la excepción que te esconde el cuerpo del error.
⏱ 15 min de lectura
Un programa que habla con una API ajena falla. No es una posibilidad: la red se cae, el servidor se reinicia, el proveedor tiene un mal día. Con una petición al mes da igual; con diez mil al día, algo falla todos los días.
Esta lección va de que el programa sobreviva a eso. Y la parte importante es que la mitad de los reintentadores que se escriben no reintentan nada, porque la configuración por defecto de la herramienta que todo el mundo usa no hace lo que parece.
La clasificación viene de la lección 2 y se concreta aquí:
Situación
¿Reintentar?
Por qué
No resuelve el dominio
Sí, con calma
El DNS falla a ratos
Conexión rechazada
Sí
El servidor puede estar reiniciándose
Tiempo de espera al conectar
Sí
Es idempotente: no llegó nada
Tiempo de espera al leer
Depende del método
Pudo llegar y procesarse
429
Sí, esperando lo que digan
Te has pasado de cuota
500502503504
Sí
El problema es suyo y puede pasar
501505
No
No lo van a implementar en dos segundos
400401403404422
No
Fallará igual la segunda vez
Las dos filas que hay que mirar dos veces:
El tiempo de espera al leer es el caso difícil. Conectaste, mandaste la petición y no llegó respuesta a tiempo. No sabes si se procesó. Con un GET da igual. Con un POST que crea un pedido, reintentar puede cobrar dos veces, y es la idempotencia de la lección 2 decidiendo por ti.
El 403 a veces sí. Varias APIs, GitHub entre ellas, usan 403 en lugar de 429 para el límite de uso. Hay que mirar el cuerpo o las cabeceras de cuota para distinguir "no puedes" de "ahora no".
requests no reintenta nada por su cuenta, y no debería. Lo que se monta es esto:
python
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
reintentos = Retry(total=3, backoff_factor=0.5)
sesion.mount('https://', HTTPAdapter(max_retries=reintentos))
Y eso, tal cual, no hace nada útil:
Salida en pantalla
GET a una dirección que devuelve 503:
devolvió 503 tras 0,4 s <- sin reintentar
Un solo viaje. Porque Retry por defecto solo reintenta errores de conexión, no códigos de respuesta. Un 503 es una respuesta perfectamente entregada, así que no entra.
GET a una dirección que devuelve 503:
tras 3,7 s -> RetryError <- ahora sí, cuatro intentos
Esta es la línea que falta en casi todo el código que anda por ahí. Es un fallo silencioso perfecto: el reintentador está puesto, se ve en el código, y no reintenta.
El primer reintento no espera nada. Es cero, siempre. La idea es que un fallo aislado —un paquete perdido— se arregla al instante y no merece penalización. Pero significa que el primer golpe es inmediato, y por tanto total=1 no da ninguna espera.
El tope son 120 segundos por defecto. Con factor 2 y siete reintentos, el último se queda en 120 en vez de 128.
Elegir el factor es elegir cuánto aguantas esperando. Con total=3 y factor 0.5, un fallo persistente cuesta tres segundos de espera más cuatro viajes de red. Con factor 2, son doce segundos. Para un guion nocturno, generoso; para algo que atiende a una persona esperando, mínimo.
allowed_methods por defecto: DELETE, GET, HEAD, OPTIONS, PUT, TRACE
POST y PATCH no están, y está bien. Es exactamente la tabla de idempotencia de la lección 2: reintentar un POST puede crear dos cosas.
Si necesitas reintentar un POST —y a veces hace falta, porque un fallo de conexión antes de enviar es seguro de reintentar— la forma correcta no es meterlo en la lista, es usar una clave de idempotencia, que también es de la lección 2:
tras 3,7 s -> RetryError
HTTPSConnectionPool(host='...'): Max retries exceeded with url: /status/503
Cuando se agotan los reintentos por código de estado, no recibes la respuesta: recibes una excepción. Y eso tiene dos consecuencias molestas:
Tu raise_for_status() nunca se ejecuta, así que el except requests.HTTPError que tenías escrito no atrapa nada. Hay que atrapar RetryError, que es hermana suya, no hija.
No puedes leer el cuerpo del error. Que es justo donde la API explicaba qué pasó, según la lección 2. El mensaje de la excepción solo dice cuántas veces lo intentó.
Se puede pedir el otro comportamiento:
python
Retry(..., raise_on_status=False)
Salida en pantalla
ahora devuelve la respuesta: 503 | se puede leer el cuerpo
Así los reintentos ocurren igual, y al agotarse te entregan la última respuesta para que la trates tú. Es lo que conviene casi siempre, porque conserva la información y deja el tratamiento de errores en un solo estilo:
python
respuesta = sesion.get(url, timeout=(3.05,15))# ya reintentó por dentro
respuesta.raise_for_status()# y esto sigue funcionando