PostgreSQL LISTEN/NOTIFY worker loop with SQLAlchemy 2.0 + psycopg2: added an except OperationalError reconnect handler around the notification poll loop so a dead LISTEN backend triggers a reconnect, but the handler never fires. Killing the LISTEN backend with SELECT pg_terminate_backend(pid) crashes the worker thread with an uncaught psycopg2.OperationalError: server closed the connection unexpectedly instead of reconnecting. The handler imported from sqlalchemy.exc import OperationalError, which looked correct — SQLAlchemy normally wraps driver errors, and the same except clause works fine for errors raised from session.execute(). A chaos test that terminated the LISTEN backend proved the reconnect path was dead code: no fresh LISTEN backend ever appeared in pg_stat_activity, and a paired non-blocking drain helper that catches broad Exception logged 'will reconnect' but nothing reconnected either.
SQLAlchemy only wraps DBAPI exceptions into sqlalchemy.exc.OperationalError when the error is raised through SQLAlchemy's execution machinery (Connection.execute, Session.execute, ...). A LISTEN/NOTIFY poll loop typically bypasses that machinery entirely: it grabs the raw driver connection (connection.connection.driver_connection) and calls driver_conn.poll() / select.select([driver_conn], ...). When the backend dies, poll() raises the raw psycopg2.OperationalError — a completely unrelated class to sqlalchemy.exc.OperationalError, despite the identical name — so except OperationalError from sqlalchemy.exc silently never matches and the exception propagates up and kills the worker loop.
Fix without adding a hard dependency on the driver: derive the DBAPI error classes from the engine's dialect and catch both:
from sqlalchemy.exc import OperationalError
@property
def _conn_error_types(self) -> tuple:
dbapi = self.engine.dialect.dbapi # the DBAPI module, e.g. psycopg2
if dbapi is None:
return (OperationalError,)
return (OperationalError, dbapi.OperationalError, dbapi.InterfaceError)
...
try:
for notification in poll(timeout=..., connection=notification_conn):
...
except self._conn_error_types:
notification_conn.close()
notification_conn = reopen_listen_connection()Include the DBAPI's InterfaceError too: after the first failure the driver connection object is closed, and subsequent poll() calls raise psycopg2.InterfaceError: connection already closed rather than OperationalError.
Verification that catches this class of bug deterministically: terminate the LISTEN backend with SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE query LIKE 'LISTEN %', then assert a fresh LISTEN % backend (new pid) appears in pg_stat_activity and that a subsequently NOTIFYed task is picked up well under the poll timeout (set the poll timeout high, e.g. 60s, so a pass cannot be periodic polling in disguise).