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
Steps to reproduce
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
Screenshots
"In the format of: "
Logs
Package Version
0.59
Platform
Summary
Since 0.59.0, executing any statement while an unrelated exception is being handled (i.e. from inside an
exceptblock) makes the compiled driver re-raise that unrelated ambient exception out ofdispatch_statement_execution— even though the statement itself executes successfully. The query result is lost and the caller's original exception propagates instead..sofiles 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_executionwas reshaped intoasync with exc_handler:+_dispatch_statement_with_cursor(...)+_check_pending_exception(...). Compiled with mypyc, that shape re-raisessys.exc_info()'s current exception on exit of the block when the caller is inside anexcepthandler.Reproduction (no database server needed)
Expected (and actual on 0.58.3):
Actual on 0.59.0 / 0.60.0 (PyPI wheels):
Same version, pure-Python source (install 0.59.0, delete
sqlspec/**/*.so, rerun): printsquery 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:
On 0.59+, the first driver call inside the
exceptblock re-raisesPermissionDeniedException, 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.pybase,dispatch_statement_execution).Environment
aiosqlite(no server) andasyncpg(PostgreSQL 17)Notes
BaseAsyncExceptionHandler.__aexit__correctly returnsFalsewhenexc_val is None, and_check_pending_exceptiononly raises whenpending_exceptionis set — the pure-Python semantics are right. The re-raise of the ambient exception must come from mypyc's handling of theasync 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).URL to code causing the issue
No response
MCVE
# Your MCVE code hereSteps to reproduce
Screenshots
"In the format of:
"Logs
Package Version
0.59
Platform