Módulo 7: Flaky en CI y tests que solo fallan allá

3. El debate del retry: `--reruns`

Descripción

Cuando un flaky bloquea el CI de todos (lección 2), el equipo alcanza casi por instinto una herramienta que promete alivio inmediato: el retry automático. La idea es tentadora en su simplicidad: si el test falla, que pytest lo vuelva a correr solo, dos o tres veces; si en alguno de esos intentos pasa, cuéntalo como verde y desbloquea el PR. Nadie tiene que apretar "re-run" a mano, el pipeline se autocura, y la vida sigue. Esta lección instala esa herramienta —pytest-rerunfailures con la bandera --reruns N— y la ejecuta de verdad sobre el flaky de should_audit, para que veas con tus ojos qué hace, y luego te da el debate honesto: cuándo el retry es un analgésico legítimo y cuándo es, sencillamente, una forma automática de mentirte en verde.

Vas a ver la salida real con el marcador RERUN: una corrida donde --reruns 3 reintenta el flaky y termina 1 passed, 1 rerun —el retry lo rescató— y otra donde ni tres reintentos alcanzan y termina 1 failed, 3 rerun —el retry perdió—. Esas dos salidas, medidas ejecutando, son el debate en carne viva: el retry a veces convierte un rojo en verde sin que el código cambie, y esa es justo su virtud y su veneno a la vez.

Conexión con el módulo: la lección 2 estableció que re-correr a ciegas es tóxico. Esta lección no lo contradice: lo precisa. El retry automatizado es re-correr, pero con una diferencia —es explícito, acotado y visible en el log (RERUN)—, y esa diferencia lo vuelve tolerable como triaje de emergencia sin volverlo aceptable como cura. La lección 4 ofrece la alternativa más disciplinada (la cuarentena, que aísla en vez de reintentar toda la suite), y la lección 7 da la única cura real (arreglar el determinismo). El retry es el primer eslabón de esa cadena, y hay que entenderlo bien —incluido su peligro— para no quedarse en él.

El botón de "reintentar" del cajero automático

Piensa en un cajero automático que a veces, por un hipo de la red, rechaza tu retiro con un "operación fallida, intente de nuevo". Aprietas "reintentar", y la segunda vez funciona. El botón de reintentar es útil y honesto cuando el fallo es de verdad transitorio —un paquete de red que se perdió, una latencia momentánea—: reintentar no oculta ningún problema real, solo sortea un tropiezo pasajero que no volverá a importar.

Pero imagina otro cajero que rechaza tu retiro porque de verdad no tienes fondos. Aprietas "reintentar" y... a veces funciona, porque el saldo que el cajero lee está desincronizado y en el segundo intento lee un número viejo que sí alcanza. Reintentar "resolvió" el problema —te dio el dinero—, pero no resolvió nada: el problema real (tu saldo, la desincronización) sigue ahí, y ahora está escondido detrás de un reintento exitoso. El día que la desincronización juegue al revés, o que el banco cuadre las cuentas, el problema reaparece, peor y más tarde.

El retry de tests es exactamente ese botón. Reintentar un test flaky por una causa de verdad transitoria —una red que parpadeó en un test de integración— es como el primer cajero: legítimo, sortea un tropiezo pasajero. Reintentar un test que falla por un bug real intermitente —un problema de orden, un estado mal manejado, una condición de carrera— es como el segundo cajero: el verde que obtienes esconde un problema que sigue vivo y reaparecerá peor. Y el retry, por sí solo, no sabe distinguir un caso del otro: reintenta igual, y te entrega el verde igual. Distinguirlos es tu trabajo, no el suyo.

El retry (--reruns) reintenta un test que falla y lo cuenta como verde si algún intento pasa. Es legítimo para un fallo de verdad transitorio; es peligroso para un bug intermitente real, porque lo convierte en un verde mentiroso. La herramienta no distingue cuál es cuál —tú sí tienes que hacerlo—.

Instalar y correr el retry, de verdad

pytest-rerunfailures es un plugin de pytest que agrega la capacidad de reintentar tests fallidos. Se instala con pip en el mismo venv donde corres pytest:

pip install pytest-rerunfailures

Una vez instalado, pytest gana la bandera --reruns N: "si un test falla, vuélvelo a correr hasta N veces más; si en alguno pasa, cuéntalo como pasado". No hay que tocar el código de los tests para el modo global; la bandera aplica a toda la suite. Verifiquemos que quedó instalado, porque el plugin aparece en la cabecera de pytest —una forma honesta de confirmar que está activo—:

python -m pytest --version

En la máquina donde escribo esto, con el plugin instalado, la cabecera de cualquier corrida ahora incluye la línea plugins: rerunfailures-16.4. Ese plugins: es tu confirmación de que el retry está disponible; si no aparece, la bandera --reruns daría un error de "opción desconocida".

Ejemplo trabajado 1: --reruns 3 rescata al flaky

Corramos solo el flaky, con --reruns 3, en modo verboso (-v) para ver cada intento en su propia línea. La bandera dice "si test_new_booking_is_audited falla, reintenta hasta 3 veces más". Con Python 3.14.0, pytest 9.1.1 y rerunfailures 16.4, esta es una corrida real donde el primer intento cayó en microsegundo impar (falló) y el reintento cayó en par (pasó):

python -m pytest tests/test_audit.py::test_new_booking_is_audited --reruns 3 -v

Qué esperar.

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- /private/tmp/reservo-m7/.venv/bin/python
cachedir: .pytest_cache
rootdir: /private/tmp/reservo-m7
plugins: rerunfailures-16.4
collecting ... collected 1 item

tests/test_audit.py::test_new_booking_is_audited RERUN                   [100%]
tests/test_audit.py::test_new_booking_is_audited PASSED                  [100%]

========================== 1 passed, 1 rerun in 0.01s ==========================

Léelo línea por línea. El primer intento falló, y en vez de reportar FAILED, pytest imprimió RERUN —la marca del plugin: "esto falló, lo voy a reintentar"—. El segundo intento cayó en un microsegundo par, should_audit() devolvió True, y el test pasó: PASSED. El resumen final lo cuenta como 1 passed, 1 rerun: un test pasado (para todos los efectos del gate, verde) y un reintento consumido. El PR se desbloquea. Nadie tocó el código. El flaky sigue tan flaky como antes —solo que esta vez el dado cayó bien en el segundo tiro—.

Ese 1 rerun en el resumen es importante: es la huella visible de que hubo un reintento. A diferencia de apretar "re-run" a mano (que no deja rastro en el log del intento anterior), el retry automatizado deja constancia de que el test necesitó reintentarse. Esa constancia es lo que separa un retry disciplinado —que sabes que ocurrió y puedes contar— de un re-correr a ciegas que se pierde en el olvido.

Ejemplo trabajado 2: cuando ni tres reintentos alcanzan

El retry no garantiza el verde. Si el flaky falla en los cuatro intentos (el original más los tres reintentos), el test se reporta como fallado. Con un flaky que falla ~50% de las veces, que los cuatro tiros caigan mal tiene probabilidad ≈ 6% —poco, pero pasa—. Aquí una corrida real donde ocurrió:

python -m pytest tests/test_audit.py::test_new_booking_is_audited --reruns 3 -v

Qué esperar.

collecting ... collected 1 item

tests/test_audit.py::test_new_booking_is_audited RERUN                   [100%]
tests/test_audit.py::test_new_booking_is_audited RERUN                   [100%]
tests/test_audit.py::test_new_booking_is_audited RERUN                   [100%]
tests/test_audit.py::test_new_booking_is_audited FAILED                  [100%]

=================================== FAILURES ===================================
_________________________ test_new_booking_is_audited __________________________

    def test_new_booking_is_audited():
        # Intencion del autor: "una reserva nueva se audita".
        # BUG: should_audit() sin argumento lee el reloj real, y devuelve True solo
        # cuando el microsegundo es par (~la mitad de las veces). Este test es FLAKY
        # por construccion: a veces pasa, a veces falla, sin tocar el codigo.
>       assert should_audit() is True
E       assert False is True
E        +  where False = should_audit()

tests/test_audit.py:11: AssertionError
=========================== short test summary info ============================
FAILED tests/test_audit.py::test_new_booking_is_audited - assert False is True
========================== 1 failed, 3 rerun in 0.03s ==========================

Tres RERUN seguidos —los tres reintentos— y al final FAILED: los cuatro tiros cayeron en microsegundo impar. El resumen: 1 failed, 3 rerun. El retry consumió sus tres oportunidades y perdió. El PR sigue bloqueado.

Esta salida enseña algo crucial sobre el retry: es una apuesta, no una garantía. Con --reruns 3 sobre un flaky del 50%, desbloqueas el 94% de las veces y sigues bloqueado el 6%. Subir a --reruns 10 sube la probabilidad de verde (99.9%), pero fíjate en lo que estás haciendo: cuantos más reintentos permites, más agresivamente conviertes rojos en verdes, y más profundamente entierras cualquier problema real que se esconda detrás del flaky. El número de reruns es una perilla que cambia cuánto mientes en verde, no si mientes.

El debate, con las dos salidas en la mano

Ahora que viste el retry rescatar y perder, el debate honesto.

A favor: el retry desbloquea al equipo sin trabajo manual. El daño uno de la lección 2 —el flaky bloquea el PR de todos— es real y urgente. Un equipo con un flaky vivo y sin retry pierde horas apretando "re-run" a mano, con la frustración y el reflejo tóxico que eso entrena. El retry automatiza ese desbloqueo: es explícito (está en el YAML, no en el instinto de cada quien), acotado (N intentos, no infinitos) y visible (RERUN en el log, X rerun en el resumen). Si tu flaky es un fallo de verdad transitorio —el test de integración cuya red parpadea una vez de cada mil— reintentar es la respuesta correcta, no un parche: no hay nada que arreglar en el código, solo un tropiezo pasajero que sortear. Como el primer cajero.

En contra: el retry esconde bugs reales y perpetúa el flaky. Si tu flaky no es un tropiezo transitorio sino un bug intermitente real —una condición de carrera, un estado compartido, un error de redondeo que depende del orden—, el retry es el segundo cajero: te da el verde y esconde el problema, que sigue vivo y reaparecerá peor. Y aun cuando la causa sea "inofensiva" como nuestro reloj, el retry perpetúa el flaky: cada verde-por-reintento es una razón menos para arreglarlo de raíz, así que el flaky vive para siempre, consumiendo reruns y erosionando la confianza de fondo. El retry alivia el síntoma agudo (el bloqueo de hoy) a cambio de cronificar la enfermedad (el flaky que nunca se cura).

El veredicto de la industria, y de esta guía: el retry es un analgésico, no un antibiótico. Se justifica como triaje —para desbloquear al equipo hoy mientras haces el trabajo real— y solo si viene con dos compromisos: (1) un ticket que registre el flaky para arreglarlo, y (2) visibilidad de que el reintento ocurrió, para que "verde" no borre la deuda. Un retry sin ticket ni visibilidad no es triaje, es negación automatizada. Y bajo ninguna circunstancia el retry es la meta: la meta es que el test no necesite reintentarse (lección 7).

Cómo activarlo: CLI y configuración

Hay dos formas de encender el retry, y la elección dice mucho sobre tu intención.

Por CLI, para una corrida puntual: pytest --reruns 3. Útil cuando reintentas a propósito y de forma consciente —"hoy necesito desbloquear, corro con reruns una vez"—. No queda pegado a la suite; la próxima corrida sin la bandera no reintenta.

Por configuración, para toda la suite, siempre: en pytest.ini (o pyproject.toml), con addopts:

# pytest.ini
[pytest]
addopts = --reruns 2

Con eso, toda corrida de pytest reintenta hasta 2 veces, sin escribir la bandera. Lo verifiqué ejecutando: con ese pytest.ini, una corrida normal del flaky imprime configfile: pytest.ini en la cabecera y reintenta sola, terminando —en una corrida real— 1 passed, 1 rerun. Es cómodo... y peligroso. Un --reruns global escondido en la config reintenta todo, incluidos los tests que deberían fallar honestamente. Enmascara flaky nuevos antes de que los notes, y vuelve el retry invisible para quien no lea la config. Si vas a usar retry global, que sea una decisión de equipo documentada, con reruns bajos (1–2), y jamás como sustituto de arreglar. Preferible es el retry por test (la cuarentena de la lección 4), que reintenta solo el flaky marcado y deja al resto de la suite fallar honestamente.

Errores comunes

Subir los reruns hasta que el flaky "desaparezca". Qué pasa: --reruns 3 no siempre da verde, así que alguien lo sube a --reruns 10 "para asegurar". Por qué pasa: más reintentos = más verde, y se confunde verde con sano. Cómo detectarlo: si estás subiendo el número de reruns para tapar un flaky, estás midiendo cuánto quieres mentir, no arreglando nada. Cómo corregirlo: el número de reruns no debería crecer nunca para acomodar un flaky; si --reruns 2 no basta como triaje, el flaky es lo bastante grave como para ponerlo en cuarentena (lección 4) y arreglarlo (lección 7), no para reintentarlo con más fuerza.

Retry global en addopts sin que el equipo lo sepa. Qué pasa: alguien pone --reruns 2 en pytest.ini para calmar un flaky, y ahora toda la suite reintenta en silencio, incluidos bugs reales que fallaban honestamente. Por qué pasa: es un arreglo de una línea que "hace desaparecer el problema" para todos. Cómo detectarlo: revisa addopts; si hay un --reruns que nadie recuerda haber discutido, es retry invisible. Cómo corregirlo: el retry global es una decisión de equipo, documentada y con reruns mínimos; para un flaky específico, prefiere el retry por-test (@pytest.mark.flaky, lección 4), que es visible y acotado al culpable.

Tratar 1 passed, 1 rerun igual que 1 passed. Qué pasa: el resumen dice 1 passed, 1 rerun y el desarrollador lee solo "passed", ignorando el 1 rerun. Por qué pasa: el verde tranquiliza y el rerun es letra chica. Cómo detectarlo: si tu suite reporta reruns y nadie los mira, estás desperdiciando la única señal honesta que el retry te da. Cómo corregirlo: el X rerun es información, no adorno —cada rerun es un flaky que necesitó un empujón—. Cuenta los reruns, registra qué test los provocó, y trátalos como deuda pendiente. Un 1 passed, 1 rerun significa "pasó, pero tuve que reintentar": no es lo mismo que "pasó limpio".

Ejercicios

Ejercicio 1 — Predice el resumen. Corres pytest tests/test_audit.py::test_new_booking_is_audited --reruns 3. El flaky falla ~50% de las veces, de forma independiente en cada intento. (a) ¿Qué resumen ves si el primer intento pasa? (b) ¿Y si el primero falla y el segundo pasa? (c) ¿Y si los cuatro intentos fallan? Escribe el resumen de cada caso.

Ver solución
  • (a) Primer intento pasa: no hay reintento —el retry solo actúa sobre fallos—. Resumen: 1 passed (sin ningún rerun). El --reruns 3 estaba disponible pero no se usó.
  • (b) Falla el primero, pasa el segundo: un reintento, exitoso. Resumen: 1 passed, 1 rerun (con una línea RERUN y luego PASSED en modo verboso). Es la corrida del ejemplo trabajado 1.
  • (c) Los cuatro fallan: el original más los tres reintentos, todos rojos. Resumen: 1 failed, 3 rerun (tres líneas RERUN y luego FAILED). Es la corrida del ejemplo trabajado 2.

La clave: el X rerun cuenta reintentos consumidos, no intentos totales. Un test que pasa a la primera tiene 0 reruns; uno que pasa al segundo intento tiene 1 rerun; uno que agota los tres reintentos y falla tiene 3 reruns. Y passed/failed refleja el resultado final tras todos los reintentos permitidos.

Ejercicio 2 — ¿Retry legítimo o retry-mentira? Para cada flaky, di si reintentarlo es un analgésico legítimo (triaje) o un retry-mentira que esconde un bug, y por qué. (a) Un test de integración que llama a una API externa y falla 1 de cada 500 veces por un timeout de red. (b) test_new_booking_is_audited, que falla ~50% por leer el reloj. (c) Un test que falla intermitentemente porque dos tests comparten un archivo temporal y a veces uno lo borra antes de que el otro lo lea.

Ver solución
  • (a) Analgésico legítimo (con matices). Un timeout de red genuinamente transitorio, 1/500, es el primer cajero: no hay bug en tu código, solo un tropiezo de la red que no volverá a importar. Reintentar es defendible aquí —aunque lo ideal es aislar ese test de integración del gate rápido y no depender de red en la suite unitaria—. El retry sortea un fallo real transitorio sin esconder nada tuyo.
  • (b) Retry-mentira (perpetúa el flaky). No hay nada transitorio: should_audit() mira el reloj por diseño, y reintentar solo vuelve a tirar el dado. El verde que obtienes no corresponde a que algo se haya resuelto; el flaky sigue idéntico. Es triaje aceptable solo con ticket y plan de arreglo (lección 7), nunca un final: la causa es un no-determinismo que hay que quitar, no esperar.
  • (c) Retry-mentira que esconde un bug real. El archivo temporal compartido es una condición de carrera / estado compartido: un bug de verdad (segundo cajero). Reintentar te da el verde y entierra el bug, que reaparecerá —peor— cuando el orden o el paralelismo cambien (lecciones 5–6). Aquí el retry es activamente dañino: te quita la señal que te habría llevado a aislar el estado (lección 7).

La regla para distinguir: pregunta "¿la causa del fallo es un tropiezo externo genuinamente pasajero, o una fuente de no-determinismo en mi código/tests?". Lo primero puede reintentarse; lo segundo hay que arreglarlo, y el retry solo lo esconde.

Ejercicio 3 — El retry con condiciones. Tu equipo decide usar retry como triaje mientras arregla el flaky de auditoría. Escribe las dos condiciones que la lección exige para que ese retry sea disciplinado y no negación automatizada, y explica qué mala consecuencia previene cada una.

Ver solución

Las dos condiciones:

  1. Un ticket que registre el flaky. Previene que el retry se vuelva permanente por olvido: sin un ticket, el "vamos a reintentar mientras lo arreglamos" se convierte en "lo reintentamos para siempre", porque nada obliga a volver. El ticket es el compromiso explícito de que el retry es temporal y que hay trabajo pendiente (el arreglo de la lección 7). Sin él, el analgésico se vuelve la dieta.

  2. Visibilidad de que el reintento ocurrió (el X rerun en el resumen, contado y revisado). Previene que "verde" borre la deuda: si nadie mira los reruns, el equipo pierde de vista que hay un flaky vivo, y con eso pierde también la capacidad de notar si empeora o si un flaky nuevo se sumó. La visibilidad mantiene el flaky en el radar aunque el retry lo desbloquee.

Juntas convierten el retry de "esconder el problema automáticamente" en "desbloquear hoy sin perder de vista que hay que curar". La consecuencia que ambas previenen, en una frase: que el triaje se disfrace de solución y el flaky viva para siempre bajo un verde cómodo.

Resumen y siguiente paso

En esta lección instalaste pytest-rerunfailures y corriste --reruns 3 de verdad sobre el flaky de should_audit. Viste el retry rescatarlo —RERUN seguido de PASSED, resumen 1 passed, 1 rerun— y perder —tres RERUN y FAILED, resumen 1 failed, 3 rerun—, y con esas dos salidas en la mano tuviste el debate honesto: el retry desbloquea al equipo sin trabajo manual (a favor) pero esconde bugs reales y perpetúa el flaky (en contra). Como el botón de reintentar del cajero: legítimo ante un tropiezo transitorio, peligroso ante un problema real que sigue vivo detrás del verde.

El veredicto: el retry es un analgésico, no un antibiótico. Se justifica como triaje —con ticket y visibilidad de los reruns—, nunca como cura ni como meta. Aprendiste a activarlo por CLI (--reruns 3, consciente y puntual) y por config (addopts, global y peligroso), y por qué el retry por-test es preferible al global.

Antes de avanzar deberías poder: instalar y correr --reruns N; leer un resumen con X rerun y distinguir 1 passed, 1 rerun de 1 passed; argumentar cuándo reintentar es legítimo y cuándo es un retry-mentira; y nombrar las dos condiciones (ticket + visibilidad) que vuelven al retry disciplinado.

Lo que sigue, en la lección 4, es la alternativa más quirúrgica al retry global: la cuarentena. En vez de reintentar toda la suite a ciegas, marcas solo el flaky —con @pytest.mark.flaky para reintentarlo por test, o con xfail para sacarlo del gate mientras lo investigas— dejando al resto de la suite fallar honestamente. Vas a ver ambas ejecutadas, con su xfailed/xpassed real, y la disciplina que las hace sanas: siempre con ticket, siempre temporales.

Recursos

  • pytest-rerunfailures — repositorio en GitHub — la documentación canónica del plugin: --reruns, --reruns-delay, el marcador @pytest.mark.flaky, y las advertencias de los propios autores sobre no usarlo para esconder bugs. Léela para ver las opciones completas que aquí solo asomamos.
  • pytest-rerunfailures — PyPI — la página de instalación (pip install pytest-rerunfailures) y la matriz de compatibilidad con versiones de pytest. Confirma que corre con pytest 9.1.1, el de esta guía.
  • Configuración: addopts — documentación de pytest — cómo poner banderas por defecto en pytest.ini/pyproject.toml, incluido el --reruns global que esta lección advierte usar con cuidado. Útil para entender qué corre tu suite aunque no lo escribas en la línea de comandos.
  • Flaky tests — documentación de pytest — vuelve a la sección sobre reintentos: pytest mismo enmarca el retry como triaje temporal, no como solución, exactamente el veredicto de esta lección.