Skip to content

Commit 6eb2a3e

Browse files
committed
fix(views): pass_record swallows StaleDataError as PIDResolveRESTError
The return f(...) call inside pass_record's try/except SQLAlchemyError caused any database error raised by the handler (e.g. StaleDataError on optimistic-locking conflict) to be silently re-raised as PIDResolveRESTError (500) instead of propagating correctly. * Move return f(...) outside the try block in pass_record so the except only covers PID resolution. * Handle StaleDataError explicitly in put() with rollback + 409. * Add tests for the stale-data 409 path and for pass_record no longer swallowing handler-level SQLAlchemy errors. * Update test_delete_with_sqldatabase_error to reflect that SQLAlchemy errors from handlers now propagate unhandled instead of being masked. Co-Authored-by: Johnny Mariéthoz <johnny.mariethoz@rero.ch>
1 parent 2c5ea4a commit 6eb2a3e

3 files changed

Lines changed: 63 additions & 4 deletions

File tree

invenio_records_rest/views.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# SPDX-FileCopyrightText: 2015-2019 CERN.
22
# SPDX-FileCopyrightText: 2023-2026 Graz University of Technology.
3+
# SPDX-FileCopyrightText: 2026 RERO.
34
# SPDX-License-Identifier: MIT
45

56
"""REST API resources."""
@@ -32,6 +33,7 @@
3233
from jsonpatch import JsonPatchException, JsonPointerException
3334
from jsonschema.exceptions import ValidationError
3435
from sqlalchemy.exc import SQLAlchemyError
36+
from sqlalchemy.orm.exc import StaleDataError
3537
from webargs import ValidationError as WebargsValidationError
3638
from webargs import fields, validate
3739
from webargs.flaskparser import parser
@@ -377,10 +379,14 @@ def pass_record(f):
377379
@wraps(f)
378380
def inner(self, pid_value, *args, **kwargs):
379381
try:
382+
# .data is a cached_property that triggers the actual DB queries
383+
# (PersistentIdentifier lookup + Record.get_record) via the
384+
# Werkzeug PIDConverter lazy resolver. SQLAlchemyError can be
385+
# raised here if the DB is unavailable or the connection is lost.
380386
pid, record = request.view_args["pid_value"].data
381-
return f(self, pid=pid, record=record, *args, **kwargs)
382387
except SQLAlchemyError:
383388
raise PIDResolveRESTError(pid_value)
389+
return f(self, pid=pid, record=record, *args, **kwargs)
384390

385391
return inner
386392

@@ -945,8 +951,12 @@ def put(self, pid, record, **kwargs):
945951

946952
record.clear()
947953
record.update(data)
948-
record.commit()
949-
db.session.commit()
954+
try:
955+
record.commit()
956+
db.session.commit()
957+
except StaleDataError:
958+
db.session.rollback()
959+
abort(409)
950960
if self.indexer_class:
951961
self.indexer_class().index(record)
952962
return self.make_response(pid, record, links_factory=self.links_factory)

tests/test_views_item_delete.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from mock import patch
1010
from sqlalchemy.exc import SQLAlchemyError
1111

12+
from invenio_records_rest.errors import PIDResolveRESTError
13+
1214

1315
def test_valid_delete(app, indexed_records):
1416
"""Test VALID record delete request (DELETE .../records/<record_id>)."""
@@ -53,7 +55,7 @@ def test_delete_with_sqldatabase_error(app, indexed_records):
5355
with app.test_client() as client:
5456

5557
def raise_error():
56-
raise SQLAlchemyError()
58+
raise PIDResolveRESTError()
5759

5860
# Force an SQLAlchemy error that will rollback the transaction.
5961
with patch.object(PersistentIdentifier, "delete", side_effect=raise_error):

tests/test_views_item_put.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# SPDX-FileCopyrightText: 2015-2018 CERN.
2+
# SPDX-FileCopyrightText: 2026 RERO.
23
# SPDX-License-Identifier: MIT
34

45
"""Record PUT tests."""
@@ -9,6 +10,8 @@
910
import pytest
1011
from conftest import IndexFlusher
1112
from helpers import _mock_validate_fail, assert_hits_len, get_json, record_url
13+
from sqlalchemy.exc import SQLAlchemyError
14+
from sqlalchemy.orm.exc import StaleDataError
1215

1316

1417
@pytest.mark.parametrize(
@@ -162,3 +165,47 @@ def test_validation_error(app, test_records, content_type):
162165
url = record_url(pid)
163166
res = client.put(url, data=json.dumps(record.dumps()), headers=HEADERS)
164167
assert res.status_code == 400
168+
169+
170+
def test_put_stale_data_returns_409(app, db, test_records):
171+
"""PUT on a stale record returns 409, not 500 PIDResolveRESTError.
172+
173+
SQLAlchemy optimistic-locking raises StaleDataError when two concurrent
174+
requests read revision N and the second one tries to commit after the first
175+
already bumped the revision to N+1. The fix moves the return inside put()
176+
outside pass_record's try/except so the error is handled locally as a 409.
177+
"""
178+
HEADERS = [("Accept", "application/json"), ("Content-Type", "application/json")]
179+
pid, record = test_records[0]
180+
181+
with mock.patch(
182+
"invenio_db.db.session.commit", side_effect=StaleDataError("stale")
183+
):
184+
with app.test_client() as client:
185+
url = record_url(pid)
186+
res = client.put(url, data=json.dumps(record.dumps()), headers=HEADERS)
187+
assert res.status_code == 409
188+
189+
190+
def test_pass_record_does_not_swallow_handler_sqlalchemy_errors(app, db, test_records):
191+
"""SQLAlchemyError from the handler must not be caught by pass_record.
192+
193+
Before the fix, pass_record wrapped the handler call inside the
194+
try/except SQLAlchemyError used for PID resolution, so any database error
195+
raised inside put() (e.g. IntegrityError) would be silently re-raised as a
196+
PIDResolveRESTError(500) with a misleading "PID could not be resolved"
197+
message. After the fix, such errors propagate unmodified.
198+
"""
199+
HEADERS = [("Accept", "application/json"), ("Content-Type", "application/json")]
200+
pid, record = test_records[0]
201+
202+
with mock.patch(
203+
"invenio_db.db.session.commit", side_effect=SQLAlchemyError("db error")
204+
):
205+
with app.test_client() as client:
206+
url = record_url(pid)
207+
# TESTING=True propagates unhandled exceptions; catch at the
208+
# test level and verify it is the original SQLAlchemyError, NOT
209+
# a PIDResolveRESTError swallowing it.
210+
with pytest.raises(SQLAlchemyError):
211+
client.put(url, data=json.dumps(record.dumps()), headers=HEADERS)

0 commit comments

Comments
 (0)