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 aretry
. - Cuando NO estemos en modo
test
, queremos llamar aretry
.
- Cuando estemos en modo
- A mí personalmente me gusta usar una variable llamada
PROFILE
y asignarle el valortest
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!