Cómo prevenir connection leaks en la base de datos
Un handler que lanza una excepción con ciertos inputs pierde una conexión a la base de datos por cada request fallido. Después de treinta, el pool queda vacío y requests sanos empiezan a morir con timeouts. Por qué la limpieza va en finally, y dónde ponerla para que nadie tenga que acordarse.
- databases
- python
- backend
La parte 1 explicó el formato: posts cortos, una práctica de bases de datos en cada uno. Aquí va la primera de verdad — el bug con el nombre más aburrido que conozco, capaz de tumbar un servicio completo: olvidar devolver una conexión a la base de datos cuando el request falla.
El contexto: las conexiones se prestan, no se poseen
Abrir una conexión a la base de datos es lento, y una base de datos solo aguanta cierta cantidad de conexiones a la vez. Por eso todo backend serio mantiene un pool pequeño de conexiones ya abiertas, compartido por el servicio entero — en uno de mis servicios son 20 conexiones más 10 de overflow. Treinta en total, para todo.
Cada request pide prestada una conexión (abriendo una sesión), hace su trabajo y la devuelve. La regla no perdona: lo que pides prestado, lo devuelves.
Aquí está el bug, reducido a su esqueleto:
def handle_request(engine, work):
session = Session(engine) # tomará una conexión del pool en el primer query
result = work(session) # puede lanzar una excepción
session.close() # la devuelve -- pero solo si todo salió bien
return resultCuando work lanza una excepción, session.close() nunca se ejecuta y la conexión nunca se devuelve. Eso es un connection leak (una fuga de conexiones). (Siendo estrictos: el garbage collector de CPython puede rescatar la conexión tarde o temprano, y SQLAlchemy deja un error molesto en los logs cuando pasa. Pero eso no es determinista, con engines asyncio ni siquiera ocurre, y bajo carga «tarde o temprano» no es un plan.)
Por qué esto no aparece hasta que duele
Un connection leak es invisible. En local pruebas un request a la vez, así que siempre hay una conexión libre y todo se ve bien.
Ahora ponlo en producción. Digamos que un endpoint falla con el 1% de sus inputs. Cada fallo deja una conexión sin devolver. Después de unos 30 requests fallidos — unos cuantos miles en total a esa tasa — el pool queda vacío. El siguiente request que necesita una conexión, quizá uno sano contra un endpoint completamente distinto, espera pool_timeout segundos y muere con el TimeoutError de SQLAlchemy: QueuePool limit of size 20 overflow 10 reached, connection timed out.
Desde afuera se ve exactamente como «la base de datos está caída». Los dashboards se ponen rojos, alguien despierta al DBA, y la base de datos está ahí, sin hacer nada. Tu servicio se estranguló solo.
El fix: la limpieza va en finally
def handle_request(engine, work):
session = Session(engine)
try:
return work(session)
finally:
session.close()Dos propiedades de finally lo hacen perfecto para esto:
- Se ejecuta en todos los caminos: éxito, excepción, incluso un
returnanticipado. - No se traga la excepción. El caller sigue viendo el error original; limpiaste, no escondiste nada.
Hazlo una vez, en un solo lugar
El fix real no es acordarse del try/finally en cada handler — es lograr que ningún handler tenga que hacerlo. La vida de la sesión debe tener un solo dueño. En una app de FastAPI, ese dueño es una dependencia con yield:
def get_db():
session = SessionLocal()
try:
yield session
finally:
session.close()FastAPI ejecuta el código después del yield incluso cuando el endpoint lanza una excepción. Un middleware que abre una sesión por request y la cierra en un finally funciona igual, y para código sin request — un consumer de una cola, un cron job — un context manager es el mismo patrón con mejor sintaxis: un bloque with es un try/finally de traje y corbata.
Qué recordar
- Recurso prestado → se devuelve en
finally. Conexiones, file handles, locks — la misma regla. - Los connection leaks se esconden en el camino del error y solo aparecen bajo carga. Prueba el camino que falla, no solo el feliz.
- La vida del recurso tiene un solo dueño (dependencia, middleware, context manager) para que ningún handler pueda olvidarlo.
- Si tu servicio muere con timeouts del pool mientras la base de datos se ve tranquila, sospecha de un connection leak antes que de la base de datos.