Skip to content

Reintentar el login para que un fallo de DNS no mate al bot - #34

Merged
Isma-L154 merged 1 commit into
mainfrom
arreglo-arranque-dns-issue-33
Sep 12, 2026
Merged

Isma-L154 merged 1 commit into
mainfrom
arreglo-arranque-dns-issue-33

Conversation

@Isma-L154

Copy link
Copy Markdown
Owner

Cierra #33.

El bot murió el 2026-09-11 a las 06:18 UTC con ClientConnectorDNSError y
disparó la alerta de Discord. unattended-upgrades reemplazó glibc — que
es el resolver de DNS — y needrestart reinició el bot dentro de esa ventana.
getaddrinfo("discord.com") devolvió EAI_AGAIN, la excepción salió de
main() y el proceso terminó con 1. Dos arranques murieron así antes de que el
tercero entrara.

319 tests pasando (antes 293). +406 líneas, −1.

Dónde estaba realmente el agujero

Client.start() es login() seguido de connect(). Leyendo discord.py, solo
la primera mitad estaba expuesta:

# discord/client.py
async def start(self, token, *, reconnect=True):
    await self.login(token)
    await self.connect(reconnect=reconnect)

connect(reconnect=True) ya es resiliente: su bucle captura
aiohttp.ClientError, OSError, HTTPException, GatewayNotFound,
ConnectionClosed y asyncio.TimeoutError, y reintenta con backoff propio. Un
gateway que se cae a mitad de canción ya se recuperaba solo.

login() no tiene reintento ninguno. Por eso unos segundos sin resolver
eran fatales, y por eso el traceback apuntaba a static_login → /users/@me.

Así que main() llama a las dos mitades por separado: el login se reintenta, y
el gateway se entrega a discord.py sin envolver — apilar nuestra política de
reintentos sobre la suya duplicaría cada espera.

Qué se reintenta y qué no

Sí DNS (ClientConnectorDNSError), OSError, timeouts, GatewayNotFound, y los 5xx de Discord
No LoginFailure, PrivilegedIntentsRequired, los 4xx, TypeError

Un token equivocado o un intent que falta es un error de despliegue: va a fallar
igual para siempre, y machacar el endpoint de login encima invita a un
rate-limit. Un 4xx es culpa nuestra y lo sigue siendo.

Backoff de 5s, 15s, 30s, 60s, 120s. Agotados los intentos se relanza el último
error
, a propósito: systemd sigue siendo la red de seguridad. Reintentar para
siempre dejaría un bot que systemctl ve vivo, que nunca funciona y que nunca
dispara OnFailure. Un test fija ese presupuesto entre 60 y 600 segundos.

El bug que encontró el test de integración

Reintentar el login no es llamarlo dos veces, y uno de los tests lo demostró.

HTTPClient.static_login crea una aiohttp.ClientSession nueva en cada
llamada
y abandona la anterior. Un reintento ingenuo filtra una sesión por
intento — justo el tipo de fuga lenta que el brief prohíbe en una caja de
768 MB.

Pero cerrarla tampoco basta. A la sesión se le pasa el connector del HTTPClient,
y el connector_owner=True por defecto de aiohttp significa que cerrar la
sesión cierra el connector compartido. Como ClientSession.closed consulta
el estado del connector:

# aiohttp/client.py
@property
def closed(self) -> bool:
    return self._connector is None or self._connector.closed

...toda sesión posterior construida sobre él nace cerrada, y el siguiente
intento muere con RuntimeError: Session is closed en vez de reintentar. Se
limpia el connector junto con la sesión para que se construya un par nuevo.

Ese bug era invisible para los tests con un HTTP falso. Solo apareció en el
que maneja un discord.Client real con sesiones aiohttp reales contra un
resolver parcheado para fallar, y que además afirma el invariante que importa:
ninguna sesión aiohttp queda abierta tras los reintentos.

Cómo probarlo

pytest                       # 319 pasando
pytest tests/test_startup.py # los 26 de este arreglo

Verificación de extremo a extremo ya hecha contra la API real — DNS forzado a
fallar dos veces y luego restaurado:

INFO    discord.client: logging in using static token
WARNING loopify.startup: Login failed (ClientConnectorDNSError: ... Temporary failure in name resolution) — retrying in 0s
INFO    discord.client: logging in using static token
WARNING loopify.startup: Login failed (ClientConnectorDNSError: ... Temporary failure in name resolution) — retrying in 0s
INFO    discord.client: logging in using static token

RESULTADO: DNS se recuperó tras 2 fallos; luego Discord rechazó el token falso SIN reintentar.

Reproducir el incidente entero en ilserver4, tras desplegar:

sudo systemctl stop systemd-resolved
sudo systemctl restart loopify-bot
journalctl -u loopify-bot -f      # debe registrar reintentos, NO salir con 1
sudo systemctl start systemd-resolved
                                  # debe conectarse solo, sin tocar nada

Lo que no se toca

La unidad de systemd. Un After=nss-lookup.target no habría evitado esto:
quien reinició el servicio fue needrestart a mitad del upgrade, con la máquina
ya arrancada hacía horas. Restart=on-failure sigue siendo el respaldo para
todo lo que no sea el login.

On 2026-09-11 at 06:18 UTC an unattended-upgrade replaced glibc — which *is*
the DNS resolver — and needrestart restarted the bot in the middle of that
window. `getaddrinfo("discord.com")` returned EAI_AGAIN, the error propagated
out of `main()`, and the process exited 1. Two starts died that way before a
third one landed; each one sent an OnFailure alert to Discord.

`Client.start()` is `login()` followed by `connect()`, and only the first half
was exposed. `connect(reconnect=True)` already catches `aiohttp.ClientError`,
`OSError`, `GatewayNotFound` and friends and retries with its own backoff, so a
gateway that drops mid-song recovers on its own. `login()` has no retry at all,
which is why a few seconds without a resolver was fatal.

So `main()` now calls the two halves separately: the login is retried with
backoff (5s, 15s, 30s, 60s, 120s), and the gateway is handed to discord.py
untouched rather than wrapped in a second retry policy.

Only failures that waiting can fix are retried. A `LoginFailure` or a
`PrivilegedIntentsRequired` is a deployment mistake that will fail identically
forever, and retrying it invites a rate-limit on the login endpoint on top; a
4xx is ours and stays ours. DNS, refused connections, timeouts and Discord's
own 5xx are retried. After the delays run out the last error is re-raised, so a
lasting outage still reaches systemd instead of leaving a bot that looks alive
to `systemctl` and never trips OnFailure.

Retrying the login is not as simple as calling it twice, and one of the tests
here found out why. `HTTPClient.static_login` builds a new `aiohttp.ClientSession`
on every call and abandons the previous one, so a naive retry leaks a session
per attempt. Closing it is not enough either: the session is handed the
HTTPClient's connector with aiohttp's default `connector_owner=True`, so closing
the session closes that shared connector — and because `ClientSession.closed`
reports the connector's state, every later session built on it is born closed
and the next attempt dies with "Session is closed" instead of retrying. The
connector is cleared alongside the session so a fresh pair is built.

That bug was invisible to the tests using a fake HTTP client; it only appeared
in the one that drives a real `discord.Client` with real aiohttp sessions
against a resolver patched to fail. That test also asserts the invariant that
matters on a 768 MB box: no aiohttp session is left open behind the retries.

Verified end to end against the real API: with DNS made to fail twice and then
recover, the login retries and gets through, and an invalid token is then
rejected on the first attempt without a retry.

Tests: 319 passing, up from 293.

Closes #33
@Isma-L154
Isma-L154 merged commit af7178f into main Sep 12, 2026
3 checks passed
@Isma-L154
Isma-L154 deleted the arreglo-arranque-dns-issue-33 branch September 12, 2026 04:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant