Cómo usar @retry en tus tests sin hacerlos lentos

Unos días atrás estaba ejecutando unos tests que deberían ser relativamente rápidos cuando vi algo que no cuadraba: estaban tardando demasiado. Hoy os cuento cómo la librería retry estaba haciendo que los tests fueran mucho más lentos y ¡yo ni lo sabía!

¿Qué es @retry?

Por si alguien no sabe de qué estoy hablando, retry es una librería que permite reintentar automáticamente la ejecución de una función cuando se produce una excepción. Es muy útil para manejar errores temporales, como fallos de red o de servicios externos.

Se utiliza mediante un decorador (@retry) que permite configurar el número de intentos, el tiempo de espera entre ellos, las excepciones que activan el reintento y otras opciones.

Su uso mejora la resiliencia del código sin necesidad de escribir lógica repetitiva para el manejo de errores.

El problema de usar @retry en nuestros tests

Pero, como pasa casi siempre, no todo son buenas noticias… veamos con un ejemplo cuál es el problema.

Imaginemos que queremos testear el comportamiento de un cliente HTTP cuando hace una llamada a una API externa y esta nos devuelve un error 500 (INTERNAL_SERVER_ERROR).

Este tipo de errores no siempre es posible generarlos de forma intencionada desde el lado del cliente, por ello vamos a aprovecharnos de que estamos inyectando la librería requests para simular el comportamiento esperado.

Como es habitual, empecemos por los tests:

class FailedResponse:
    def __init__(self) -> None:
        self.status_code = INTERNAL_SERVER_ERROR
        self.text = "Internal server error"

    def json(self) -> dict:
        return {"detail": self.text}


class TestDummyHttpClient:
    def test_raise_exception_when_calling_the_api_returns_an_error(self) -> None:
        with Stub() as requests:
            failed_response = FailedResponse()
            requests.get("https://pmareke.com").returns(failed_response)

        http_client = DummyHttpClient(requests)

        expect(lambda: http_client.call()).to(raise_error(DummyHttpClientException))

Nada del otro mundo. Queremos poder inicializar de forma sencilla nuestro cliente pasándole nuestro requests preparado para fallar, para a continuación hacer la llamada, que esta retorne una respuesta HTTP fallida y que, por tanto, nuestra excepción se lance.

Así que, sin más, vamos a implementar nuestro DummyHttpClient:

class DummyHttpClient:
    def __init__(self, _requests: ModuleType) -> None:
        self.requests = _requests

    @retry(exceptions, tries=3, delay=1, backoff=3)
    def call(self) -> None:
        response = self.requests.get("https://pmareke.com")

        if response.status_code == INTERNAL_SERVER_ERROR:
            raise DummyHttpClientException("Internal server error")


class DummyHttpClientException(Exception):
    pass

La implementación es realmente sencilla:

  • Inyectamos la librería requests para hacer las llamadas HTTP.
  • Implementamos un método call que hace una llamada HTTP.
  • Validamos el status_code de la misma.
  • Si este es un 500 (INTERNAL_SERVER_ERROR), levantamos una excepción.

Una vez que está implementada la clase, podemos ejecutar nuestros tests sin ningún problema:

make slow-test

uv run pytest -x -ra tests/
========================= test session starts =========================
tests/test_dummy_http_client.py .                                [100%]
========================= 1 passed in 4.04s ===========================

4 segundos ha tardado nuestro test… ¿cómo puede ser esto?

Si no estamos haciendo ni siquiera la llamada real al exterior, ya que request en nuestro test es un Stub.

¿Qué está pasando aquí? La respuesta evidente es que, pese a no estar llamando al exterior, nuestro test está siendo lento por culpa de retry. Y es que durante el mismo se están haciendo 3 reintentos, como podríamos llegar a ver si hacemos que el test falle:

------------------------------------- Captured log call -------------------------------------
WARNING  retry.api:api.py:40 Internal server error, retrying in 1 seconds...
WARNING  retry.api:api.py:40 Internal server error, retrying in 3 seconds...
================================== short test summary info ==================================

¡Make our tests great again!

Por suerte, hay una manera relativamente sencilla de solucionar esta lentitud, ¡y además de forma muy elegante!

Para que nuestro test sea realmente rápido necesitamos:

  • Saber si estamos en modo test o no:
    • Cuando estemos en modo test, queremos evitar la llamada a retry.
    • Cuando NO estemos en modo test, queremos llamar a retry.
  • A mí personalmente me gusta usar una variable llamada PROFILE y asignarle el valor test cuando es necesario.
  • Crear un decorador que abstraiga a las clases que lo usen de toda esta lógica.
def retry_unless_test(exceptions: tuple[type[Exception]]) -> Callable:
    if os.getenv("PROFILE") == "test":
        return lambda func: func
    return retry(exceptions, tries=3, delay=1, backoff=3) # type: ignore

Con el decorador listo, pasemos sin más a usarlo en nuestra clase DummyHTTPClient:

class DummyHttpClient:
    def __init__(self, _requests: ModuleType) -> None:
        self._requests = _requests

    @retry_unless_test((DummyHttpClientException,))
    def call(self) -> None:
        response = self._requests.get("https://pmareke.com")

        if response.status_code == INTERNAL_SERVER_ERROR:
            raise DummyHttpClientException("Internal server error")

Ejecutamos los tests de nuevo haciendo uso de la variable de entorno PROFILE:

make fast-test

PROFILE=test uv run pytest -x -ra tests/
========================= test session starts =========================
tests/test_dummy_http_client.py .                                [100%]
========================= 1 passed in 0.03s ===========================

Y listo, ahora nuestros tests vuelven prácticamente instantáneos (0.03s).

Conclusiones

Como hemos visto en este post, es muy importante estar atentos a nuestros tests y controlar, de vez en cuando, sus tiempos, ya que en casos como el de hoy no es algo súper evidente (4 segundos arriba o abajo es fácil no darse cuenta).

Personalmente, recomiendo mucho el uso de retry, ya que de una forma muy sencilla podemos hacer nuestro código mucho más robusto, pero eso sí, siempre dándole la importancia a los tests que merecen.

Os dejo por aquí el código para que lo veáis con calma.

¡Un saludo!