Transaction cleanup no longer masks transaction-destroying errors

A RAISE(ROLLBACK) trigger or INSERT OR ROLLBACK conflict rolls back the
entire transaction and destroys every savepoint. The cleanup paths in
atomic() and query() then raised OperationalError ("no such savepoint" /
"cannot rollback - no transaction is active"), masking the original
IntegrityError - breaking user code that catches sqlite3.IntegrityError.
Cleanup now checks conn.in_transaction first: if the error already
destroyed the transaction there is nothing left to undo, and the
original exception propagates.

Refs https://github.com/simonw/sqlite-utils/issues/769#issuecomment-4900034150

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Simon Willison 2026-07-06 22:04:00 -07:00
commit d9a0fd26e0
3 changed files with 34 additions and 6 deletions

View file

@ -602,8 +602,13 @@ class Database:
try:
yield self
except BaseException:
self.conn.execute("ROLLBACK TO SAVEPOINT {};".format(savepoint))
self.conn.execute("RELEASE SAVEPOINT {};".format(savepoint))
# An error such as a RAISE(ROLLBACK) trigger can destroy
# the whole transaction, savepoints included - cleaning up
# anyway would mask the original exception with
# "no such savepoint"
if self.conn.in_transaction:
self.conn.execute("ROLLBACK TO SAVEPOINT {};".format(savepoint))
self.conn.execute("RELEASE SAVEPOINT {};".format(savepoint))
raise
else:
self.conn.execute("RELEASE SAVEPOINT {};".format(savepoint))
@ -612,13 +617,15 @@ class Database:
try:
yield self
except BaseException:
self.conn.execute("ROLLBACK")
# rollback() is a no-op if the error already destroyed the
# transaction, so the original exception propagates
self.rollback()
raise
else:
try:
self.conn.execute("COMMIT")
except BaseException:
self.conn.execute("ROLLBACK")
self.rollback()
raise
def begin(self) -> None:
@ -870,8 +877,11 @@ class Database:
return (dict(zip(keys, row)) for row in fetched)
return (dict(zip(keys, row)) for row in cursor)
finally:
if not released:
# An error occurred - undo anything the statement changed
if not released and self.conn.in_transaction:
# An error occurred - undo anything the statement changed.
# If the error itself destroyed the transaction (such as a
# RAISE(ROLLBACK) trigger) the savepoint is already gone
# and there is nothing left to undo
self.conn.execute('ROLLBACK TO "sqlite_utils_query"')
self.conn.execute('RELEASE "sqlite_utils_query"')