Skip to content

Commit 299e808

Browse files
authored
Migrated the db module to the new exception types (#318)
* Migrating db module to new exception types * Error handling for transactions * Updated integration tests * Restoring the old txn abort behavior * Updated error type in snippet * Added comment
1 parent 1210723 commit 299e808

File tree

5 files changed

+144
-84
lines changed

5 files changed

+144
-84
lines changed

firebase_admin/_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
403: exceptions.PERMISSION_DENIED,
5353
404: exceptions.NOT_FOUND,
5454
409: exceptions.CONFLICT,
55+
412: exceptions.FAILED_PRECONDITION,
5556
429: exceptions.RESOURCE_EXHAUSTED,
5657
500: exceptions.INTERNAL,
5758
503: exceptions.UNAVAILABLE,

firebase_admin/db.py

Lines changed: 47 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from six.moves import urllib
3333

3434
import firebase_admin
35+
from firebase_admin import exceptions
3536
from firebase_admin import _http_client
3637
from firebase_admin import _sseclient
3738
from firebase_admin import _utils
@@ -209,7 +210,7 @@ def get(self, etag=False, shallow=False):
209210
210211
Raises:
211212
ValueError: If both ``etag`` and ``shallow`` are set to True.
212-
ApiCallError: If an error occurs while communicating with the remote database server.
213+
FirebaseError: If an error occurs while communicating with the remote database server.
213214
"""
214215
if etag:
215216
if shallow:
@@ -236,7 +237,7 @@ def get_if_changed(self, etag):
236237
237238
Raises:
238239
ValueError: If the ETag is not a string.
239-
ApiCallError: If an error occurs while communicating with the remote database server.
240+
FirebaseError: If an error occurs while communicating with the remote database server.
240241
"""
241242
if not isinstance(etag, six.string_types):
242243
raise ValueError('ETag must be a string.')
@@ -258,7 +259,7 @@ def set(self, value):
258259
Raises:
259260
ValueError: If the provided value is None.
260261
TypeError: If the value is not JSON-serializable.
261-
ApiCallError: If an error occurs while communicating with the remote database server.
262+
FirebaseError: If an error occurs while communicating with the remote database server.
262263
"""
263264
if value is None:
264265
raise ValueError('Value must not be None.')
@@ -281,7 +282,7 @@ def set_if_unchanged(self, expected_etag, value):
281282
282283
Raises:
283284
ValueError: If the value is None, or if expected_etag is not a string.
284-
ApiCallError: If an error occurs while communicating with the remote database server.
285+
FirebaseError: If an error occurs while communicating with the remote database server.
285286
"""
286287
# pylint: disable=missing-raises-doc
287288
if not isinstance(expected_etag, six.string_types):
@@ -293,11 +294,11 @@ def set_if_unchanged(self, expected_etag, value):
293294
headers = self._client.headers(
294295
'put', self._add_suffix(), json=value, headers={'if-match': expected_etag})
295296
return True, value, headers.get('ETag')
296-
except ApiCallError as error:
297-
detail = error.detail
298-
if detail.response is not None and 'ETag' in detail.response.headers:
299-
etag = detail.response.headers['ETag']
300-
snapshot = detail.response.json()
297+
except exceptions.FailedPreconditionError as error:
298+
http_response = error.http_response
299+
if http_response is not None and 'ETag' in http_response.headers:
300+
etag = http_response.headers['ETag']
301+
snapshot = http_response.json()
301302
return False, snapshot, etag
302303
else:
303304
raise error
@@ -317,7 +318,7 @@ def push(self, value=''):
317318
Raises:
318319
ValueError: If the value is None.
319320
TypeError: If the value is not JSON-serializable.
320-
ApiCallError: If an error occurs while communicating with the remote database server.
321+
FirebaseError: If an error occurs while communicating with the remote database server.
321322
"""
322323
if value is None:
323324
raise ValueError('Value must not be None.')
@@ -333,7 +334,7 @@ def update(self, value):
333334
334335
Raises:
335336
ValueError: If value is empty or not a dictionary.
336-
ApiCallError: If an error occurs while communicating with the remote database server.
337+
FirebaseError: If an error occurs while communicating with the remote database server.
337338
"""
338339
if not value or not isinstance(value, dict):
339340
raise ValueError('Value argument must be a non-empty dictionary.')
@@ -345,7 +346,7 @@ def delete(self):
345346
"""Deletes this node from the database.
346347
347348
Raises:
348-
ApiCallError: If an error occurs while communicating with the remote database server.
349+
FirebaseError: If an error occurs while communicating with the remote database server.
349350
"""
350351
self._client.request('delete', self._add_suffix())
351352

@@ -371,7 +372,7 @@ def listen(self, callback):
371372
ListenerRegistration: An object that can be used to stop the event listener.
372373
373374
Raises:
374-
ApiCallError: If an error occurs while starting the initial HTTP connection.
375+
FirebaseError: If an error occurs while starting the initial HTTP connection.
375376
"""
376377
session = _sseclient.KeepAuthSession(self._client.credential)
377378
return self._listen_with_session(callback, session)
@@ -387,9 +388,9 @@ def transaction(self, transaction_update):
387388
value of this reference into a new value. If another client writes to this location before
388389
the new value is successfully saved, the update function is called again with the new
389390
current value, and the write will be retried. In case of repeated failures, this method
390-
will retry the transaction up to 25 times before giving up and raising a TransactionError.
391-
The update function may also force an early abort by raising an exception instead of
392-
returning a value.
391+
will retry the transaction up to 25 times before giving up and raising a
392+
TransactionAbortedError. The update function may also force an early abort by raising an
393+
exception instead of returning a value.
393394
394395
Args:
395396
transaction_update: A function which will be passed the current data stored at this
@@ -402,7 +403,7 @@ def transaction(self, transaction_update):
402403
object: New value of the current database Reference (only if the transaction commits).
403404
404405
Raises:
405-
TransactionError: If the transaction aborts after exhausting all retry attempts.
406+
TransactionAbortedError: If the transaction aborts after exhausting all retry attempts.
406407
ValueError: If transaction_update is not a function.
407408
"""
408409
if not callable(transaction_update):
@@ -416,7 +417,8 @@ def transaction(self, transaction_update):
416417
if success:
417418
return new_data
418419
tries += 1
419-
raise TransactionError('Transaction aborted after failed retries.')
420+
421+
raise TransactionAbortedError('Transaction aborted after failed retries.')
420422

421423
def order_by_child(self, path):
422424
"""Returns a Query that orders data by child values.
@@ -468,7 +470,7 @@ def _listen_with_session(self, callback, session):
468470
sse = _sseclient.SSEClient(url, session)
469471
return ListenerRegistration(callback, sse)
470472
except requests.exceptions.RequestException as error:
471-
raise ApiCallError(_Client.extract_error_message(error), error)
473+
raise _Client.handle_rtdb_error(error)
472474

473475

474476
class Query(object):
@@ -614,28 +616,19 @@ def get(self):
614616
object: Decoded JSON result of the Query.
615617
616618
Raises:
617-
ApiCallError: If an error occurs while communicating with the remote database server.
619+
FirebaseError: If an error occurs while communicating with the remote database server.
618620
"""
619621
result = self._client.body('get', self._pathurl, params=self._querystr)
620622
if isinstance(result, (dict, list)) and self._order_by != '$priority':
621623
return _Sorter(result, self._order_by).get()
622624
return result
623625

624626

625-
class ApiCallError(Exception):
626-
"""Represents an Exception encountered while invoking the Firebase database server API."""
627-
628-
def __init__(self, message, error):
629-
Exception.__init__(self, message)
630-
self.detail = error
631-
632-
633-
class TransactionError(Exception):
634-
"""Represents an Exception encountered while performing a transaction."""
627+
class TransactionAbortedError(exceptions.AbortedError):
628+
"""A transaction was aborted aftr exceeding the maximum number of retries."""
635629

636630
def __init__(self, message):
637-
Exception.__init__(self, message)
638-
631+
exceptions.AbortedError.__init__(self, message)
639632

640633

641634
class _Sorter(object):
@@ -934,7 +927,7 @@ def request(self, method, url, **kwargs):
934927
Response: An HTTP response object.
935928
936929
Raises:
937-
ApiCallError: If an error occurs while making the HTTP call.
930+
FirebaseError: If an error occurs while making the HTTP call.
938931
"""
939932
query = '&'.join('{0}={1}'.format(key, self.params[key]) for key in self.params)
940933
extra_params = kwargs.get('params')
@@ -950,33 +943,39 @@ def request(self, method, url, **kwargs):
950943
try:
951944
return super(_Client, self).request(method, url, **kwargs)
952945
except requests.exceptions.RequestException as error:
953-
raise ApiCallError(_Client.extract_error_message(error), error)
946+
raise _Client.handle_rtdb_error(error)
947+
948+
@classmethod
949+
def handle_rtdb_error(cls, error):
950+
"""Converts an error encountered while calling RTDB into a FirebaseError."""
951+
if error.response is None:
952+
return _utils.handle_requests_error(error)
953+
954+
message = cls._extract_error_message(error.response)
955+
return _utils.handle_requests_error(error, message=message)
954956

955957
@classmethod
956-
def extract_error_message(cls, error):
957-
"""Extracts an error message from an exception.
958+
def _extract_error_message(cls, response):
959+
"""Extracts an error message from an error response.
958960
959-
If the server has not sent any response, simply converts the exception into a string.
960961
If the server has sent a JSON response with an 'error' field, which is the typical
961962
behavior of the Realtime Database REST API, parses the response to retrieve the error
962963
message. If the server has sent a non-JSON response, returns the full response
963964
as the error message.
964-
965-
Args:
966-
error: An exception raised by the requests library.
967-
968-
Returns:
969-
str: A string error message extracted from the exception.
970965
"""
971-
if error.response is None:
972-
return str(error)
966+
message = None
973967
try:
974-
data = error.response.json()
968+
# RTDB error format: {"error": "text message"}
969+
data = response.json()
975970
if isinstance(data, dict):
976-
return '{0}\nReason: {1}'.format(error, data.get('error', 'unknown'))
971+
message = data.get('error')
977972
except ValueError:
978973
pass
979-
return '{0}\nReason: {1}'.format(error, error.response.content.decode())
974+
975+
if not message:
976+
message = 'Unexpected response from database: {0}'.format(response.content.decode())
977+
978+
return message
980979

981980

982981
class _EmulatorAdminCredentials(google.auth.credentials.Credentials):

integration/test_db.py

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import firebase_admin
2424
from firebase_admin import db
25+
from firebase_admin import exceptions
2526
from integration import conftest
2627
from tests import testutils
2728

@@ -359,30 +360,26 @@ def init_ref(self, path, app):
359360
admin_ref.set('test')
360361
assert admin_ref.get() == 'test'
361362

362-
def check_permission_error(self, excinfo):
363-
assert isinstance(excinfo.value, db.ApiCallError)
364-
assert 'Reason: Permission denied' in str(excinfo.value)
365-
366363
def test_no_access(self, app, override_app):
367364
path = '_adminsdk/python/admin'
368365
self.init_ref(path, app)
369366
user_ref = db.reference(path, override_app)
370-
with pytest.raises(db.ApiCallError) as excinfo:
367+
with pytest.raises(exceptions.UnauthenticatedError) as excinfo:
371368
assert user_ref.get()
372-
self.check_permission_error(excinfo)
369+
assert str(excinfo.value) == 'Permission denied'
373370

374-
with pytest.raises(db.ApiCallError) as excinfo:
371+
with pytest.raises(exceptions.UnauthenticatedError) as excinfo:
375372
user_ref.set('test2')
376-
self.check_permission_error(excinfo)
373+
assert str(excinfo.value) == 'Permission denied'
377374

378375
def test_read(self, app, override_app):
379376
path = '_adminsdk/python/protected/user2'
380377
self.init_ref(path, app)
381378
user_ref = db.reference(path, override_app)
382379
assert user_ref.get() == 'test'
383-
with pytest.raises(db.ApiCallError) as excinfo:
380+
with pytest.raises(exceptions.UnauthenticatedError) as excinfo:
384381
user_ref.set('test2')
385-
self.check_permission_error(excinfo)
382+
assert str(excinfo.value) == 'Permission denied'
386383

387384
def test_read_write(self, app, override_app):
388385
path = '_adminsdk/python/protected/user1'
@@ -394,9 +391,9 @@ def test_read_write(self, app, override_app):
394391

395392
def test_query(self, override_app):
396393
user_ref = db.reference('_adminsdk/python/protected', override_app)
397-
with pytest.raises(db.ApiCallError) as excinfo:
394+
with pytest.raises(exceptions.UnauthenticatedError) as excinfo:
398395
user_ref.order_by_key().limit_to_first(2).get()
399-
self.check_permission_error(excinfo)
396+
assert str(excinfo.value) == 'Permission denied'
400397

401398
def test_none_auth_override(self, app, none_override_app):
402399
path = '_adminsdk/python/public'
@@ -405,14 +402,14 @@ def test_none_auth_override(self, app, none_override_app):
405402
assert public_ref.get() == 'test'
406403

407404
ref = db.reference('_adminsdk/python', none_override_app)
408-
with pytest.raises(db.ApiCallError) as excinfo:
405+
with pytest.raises(exceptions.UnauthenticatedError) as excinfo:
409406
assert ref.child('protected/user1').get()
410-
self.check_permission_error(excinfo)
407+
assert str(excinfo.value) == 'Permission denied'
411408

412-
with pytest.raises(db.ApiCallError) as excinfo:
409+
with pytest.raises(exceptions.UnauthenticatedError) as excinfo:
413410
assert ref.child('protected/user2').get()
414-
self.check_permission_error(excinfo)
411+
assert str(excinfo.value) == 'Permission denied'
415412

416-
with pytest.raises(db.ApiCallError) as excinfo:
413+
with pytest.raises(exceptions.UnauthenticatedError) as excinfo:
417414
assert ref.child('admin').get()
418-
self.check_permission_error(excinfo)
415+
assert str(excinfo.value) == 'Permission denied'

snippets/database/index.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ def increment_votes(current_value):
214214
try:
215215
new_vote_count = upvotes_ref.transaction(increment_votes)
216216
print('Transaction completed')
217-
except db.TransactionError:
217+
except db.TransactionAbortedError:
218218
print('Transaction failed to commit')
219219
# [END transaction]
220220

0 commit comments

Comments
 (0)