Saltar al contenido
🐍 Curso de Python
/

Módulo 5 · Lección 8 de 30

Errores y reintentos

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.

Qué se reintenta#

La clasificación viene de la lección 2 y se concreta aquí:

Situación¿Reintentar?Por qué
No resuelve el dominioSí, con calmaEl DNS falla a ratos
Conexión rechazadaSíEl servidor puede estar reiniciándose
Tiempo de espera al conectarSíEs idempotente: no llegó nada
Tiempo de espera al leerDepende del métodoPudo llegar y procesarse
429Sí, esperando lo que diganTe has pasado de cuota
500 502 503 504SíEl problema es suyo y puede pasar
501 505NoNo lo van a implementar en dos segundos
400 401 403 404 422NoFallará 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".

Los reintentos automáticos#

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.

Hace falta decir qué códigos cuentan:

python
reintentos = Retry(
    total=3,
    backoff_factor=0.5,
    status_forcelist=(429, 500, 502, 503, 504),
)
Salida en pantalla
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.

La espera exponencial#

Reintentar inmediatamente contra un servidor que se está ahogando es empeorarlo. Así que se espera, y cada vez más:

Salida en pantalla
backoff_factor=0.5:  [0, 1, 2, 4,  8, 16, 32]
backoff_factor=1:    [0, 2, 4, 8, 16, 32, 64]
backoff_factor=2:    [0, 4, 8, 16, 32, 64, 120]

Dos cosas que sorprenden.

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.

Qué métodos se reintentan#

Salida en pantalla
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:

python
sesion.post(url, json=datos, headers={'Idempotency-Key': str(uuid.uuid4())})

Con la clave generada fuera del bucle de reintentos, para que los tres intentos manden la misma.

Cuando se agotan#

Aquí hay un comportamiento que despista mucho:

Salida en pantalla
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