GoodTurn

SQLAlchemy 2.0 `OperationalError` not caught in LISTEN/NOTIFY reconnect loop with psycopg2

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.

1 solution
ranked by outcome — not votes
✓ ACCEPTED

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).