Skip to content

bug: compiled (mypyc) driver re-raises the ambient exception when a statement runs inside an except block — regression in 0.59.0 #702

Description

@Kumzy

Summary

Since 0.59.0, executing any statement while an unrelated exception is being handled (i.e. from inside an except block) makes the compiled driver re-raise that unrelated ambient exception out of dispatch_statement_execution — even though the statement itself executes successfully. The query result is lost and the caller's original exception propagates instead.

  • 0.58.3: works
  • 0.59.0 / 0.60.0 (compiled wheels from PyPI): broken
  • 0.59.0 with the mypyc .so files removed (pure-Python source, same version): works — so the Python logic is correct and this is an interaction between the new deferred-exception pattern and mypyc codegen.

The regression appears to be introduced by the deferred-exception refactor that fixed #691 (fix: prevent mypyc async exception segfault): dispatch_statement_execution was reshaped into async with exc_handler: + _dispatch_statement_with_cursor(...) + _check_pending_exception(...). Compiled with mypyc, that shape re-raises sys.exc_info()'s current exception on exit of the block when the caller is inside an except handler.

Reproduction (no database server needed)

import asyncio

import sqlspec
from sqlspec import SQLSpec
from sqlspec.adapters.aiosqlite import AiosqliteConfig


async def main() -> None:
    print("sqlspec", sqlspec.__version__)
    spec = SQLSpec()
    config = spec.add_config(AiosqliteConfig(connection_config={"database": ":memory:"}))
    async with spec.provide_session(config) as session:
        try:
            raise ValueError("unrelated application-level error")
        except ValueError:
            # Real-world equivalent: a login handler catches an auth failure,
            # then runs a query to record/increment the failed-login counter.
            result = await session.select_one_or_none("SELECT 1 AS x")
            print("query ok:", result)


asyncio.run(main())

Expected (and actual on 0.58.3):

sqlspec 0.58.3
query ok: {'x': 1}

Actual on 0.59.0 / 0.60.0 (PyPI wheels):

Traceback (most recent call last):
  ...
  File "sqlspec_repro.py", line 26, in main
    result = await session.select_one_or_none("SELECT 1 AS x")
  File "sqlspec/driver/_async.py", line 826, in select_one_or_none
  File "sqlspec/driver/_async.py", line 557, in execute
  File "sqlspec/driver/_async.py", line 234, in dispatch_statement_execution
  File "sqlspec_repro.py", line 22, in main
    raise ValueError("unrelated application-level error")
ValueError: unrelated application-level error

Same version, pure-Python source (install 0.59.0, delete sqlspec/**/*.so, rerun): prints query ok: {'x': 1} — confirming the compiled code path is the difference.

Real-world impact

This is not an exotic pattern. Our Litestar login handler does:

try:
    user = await users_service.authenticate(username, password)
except PermissionDeniedException:
    # account-lockout bookkeeping — all of these silently break on 0.59+:
    failed_user = await users_service.get_by_email(username)   # ambient exc resurfaces here
    await users_service.increment_failed_login_attempts(failed_user.id)
    ...

On 0.59+, the first driver call inside the except block re-raises PermissionDeniedException, so the failed-login counter is never incremented — a silent loss of a security control (account lockout). In our full test suite this single mechanism produced 4 deterministic test failures plus ~220 cascading connection errors.

Both asyncpg and aiosqlite adapters are affected (the fault is in the shared sqlspec/driver/_async.py base, dispatch_statement_execution).

Environment

  • sqlspec 0.59.0 and 0.60.0 (PyPI wheels, mypyc-compiled), regression absent in 0.58.3
  • CPython 3.13.5, macOS 15 arm64 (darwin)
  • Reproduced with aiosqlite (no server) and asyncpg (PostgreSQL 17)
  • Litestar 2.23 in the real app, but the repro is framework-free

Notes

  • BaseAsyncExceptionHandler.__aexit__ correctly returns False when exc_val is None, and _check_pending_exception only raises when pending_exception is set — the pure-Python semantics are right. The re-raise of the ambient exception must come from mypyc's handling of the async with / try-finally shape introduced in the 0.59.0 refactor (possibly mypyc restoring/re-raising the "current exception" from its runtime exc-info state on block exit).
  • Happy to test candidate fixes.

URL to code causing the issue

No response

MCVE

# Your MCVE code here

Steps to reproduce

1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error

Screenshots

"In the format of: ![SCREENSHOT_DESCRIPTION](SCREENSHOT_LINK.png)"

Logs

Package Version

0.59

Platform

  • Linux
  • Mac
  • Windows
  • Other (Please specify in the description above)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions