Skip to content

Commit 2879a22

Browse files
authored
Migrating FCM Send APIs to the New Exceptions (#297)
* Migrated FCM send APIs to the new error handling regime * Moved error parsing logic to _utils * Refactored OP error handling code * Fixing a broken test * Added utils for handling googleapiclient errors * Added tests for new error handling logic * Updated public API docs * Fixing test for python3 * Cleaning up the error code lookup code * Cleaning up the error parsing APIs * Cleaned up error parsing logic; Updated docs
1 parent f69e14c commit 2879a22

File tree

6 files changed

+742
-232
lines changed

6 files changed

+742
-232
lines changed

firebase_admin/_messaging_utils.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222

2323
import six
2424

25+
from firebase_admin import exceptions
26+
2527

2628
class Message(object):
2729
"""A message that can be sent via Firebase Cloud Messaging.
@@ -797,3 +799,33 @@ def default(self, obj): # pylint: disable=method-hidden
797799
if target_count != 1:
798800
raise ValueError('Exactly one of token, topic or condition must be specified.')
799801
return result
802+
803+
804+
class ThirdPartyAuthError(exceptions.UnauthenticatedError):
805+
"""APNs certificate or web push auth key was invalid or missing."""
806+
807+
def __init__(self, message, cause=None, http_response=None):
808+
exceptions.UnauthenticatedError.__init__(self, message, cause, http_response)
809+
810+
811+
class QuotaExceededError(exceptions.ResourceExhaustedError):
812+
"""Sending limit exceeded for the message target."""
813+
814+
def __init__(self, message, cause=None, http_response=None):
815+
exceptions.ResourceExhaustedError.__init__(self, message, cause, http_response)
816+
817+
818+
class SenderIdMismatchError(exceptions.PermissionDeniedError):
819+
"""The authenticated sender ID is different from the sender ID for the registration token."""
820+
821+
def __init__(self, message, cause=None, http_response=None):
822+
exceptions.PermissionDeniedError.__init__(self, message, cause, http_response)
823+
824+
825+
class UnregisteredError(exceptions.NotFoundError):
826+
"""App instance was unregistered from FCM.
827+
828+
This usually means that the token used is no longer valid and a new one must be used."""
829+
830+
def __init__(self, message, cause=None, http_response=None):
831+
exceptions.NotFoundError.__init__(self, message, cause, http_response)

firebase_admin/_utils.py

Lines changed: 226 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,47 @@
1414

1515
"""Internal utilities common to all modules."""
1616

17+
import json
18+
import socket
19+
20+
import googleapiclient
21+
import httplib2
1722
import requests
23+
import six
1824

1925
import firebase_admin
2026
from firebase_admin import exceptions
2127

2228

23-
_STATUS_TO_EXCEPTION_TYPE = {
24-
400: exceptions.InvalidArgumentError,
25-
401: exceptions.UnauthenticatedError,
26-
403: exceptions.PermissionDeniedError,
27-
404: exceptions.NotFoundError,
28-
409: exceptions.ConflictError,
29-
429: exceptions.ResourceExhaustedError,
30-
500: exceptions.InternalError,
31-
503: exceptions.UnavailableError,
29+
_ERROR_CODE_TO_EXCEPTION_TYPE = {
30+
exceptions.INVALID_ARGUMENT: exceptions.InvalidArgumentError,
31+
exceptions.FAILED_PRECONDITION: exceptions.FailedPreconditionError,
32+
exceptions.OUT_OF_RANGE: exceptions.OutOfRangeError,
33+
exceptions.UNAUTHENTICATED: exceptions.UnauthenticatedError,
34+
exceptions.PERMISSION_DENIED: exceptions.PermissionDeniedError,
35+
exceptions.NOT_FOUND: exceptions.NotFoundError,
36+
exceptions.ABORTED: exceptions.AbortedError,
37+
exceptions.ALREADY_EXISTS: exceptions.AlreadyExistsError,
38+
exceptions.CONFLICT: exceptions.ConflictError,
39+
exceptions.RESOURCE_EXHAUSTED: exceptions.ResourceExhaustedError,
40+
exceptions.CANCELLED: exceptions.CancelledError,
41+
exceptions.DATA_LOSS: exceptions.DataLossError,
42+
exceptions.UNKNOWN: exceptions.UnknownError,
43+
exceptions.INTERNAL: exceptions.InternalError,
44+
exceptions.UNAVAILABLE: exceptions.UnavailableError,
45+
exceptions.DEADLINE_EXCEEDED: exceptions.DeadlineExceededError,
46+
}
47+
48+
49+
_HTTP_STATUS_TO_ERROR_CODE = {
50+
400: exceptions.INVALID_ARGUMENT,
51+
401: exceptions.UNAUTHENTICATED,
52+
403: exceptions.PERMISSION_DENIED,
53+
404: exceptions.NOT_FOUND,
54+
409: exceptions.CONFLICT,
55+
429: exceptions.RESOURCE_EXHAUSTED,
56+
500: exceptions.INTERNAL,
57+
503: exceptions.UNAVAILABLE,
3258
}
3359

3460

@@ -45,19 +71,69 @@ def _get_initialized_app(app):
4571
raise ValueError('Illegal app argument. Argument must be of type '
4672
' firebase_admin.App, but given "{0}".'.format(type(app)))
4773

74+
4875
def get_app_service(app, name, initializer):
4976
app = _get_initialized_app(app)
5077
return app._get_service(name, initializer) # pylint: disable=protected-access
5178

52-
def handle_requests_error(error, message=None, status=None):
79+
80+
def handle_platform_error_from_requests(error, handle_func=None):
81+
"""Constructs a ``FirebaseError`` from the given requests error.
82+
83+
This can be used to handle errors returned by Google Cloud Platform (GCP) APIs.
84+
85+
Args:
86+
error: An error raised by the requests module while making an HTTP call to a GCP API.
87+
handle_func: A function that can be used to handle platform errors in a custom way. When
88+
specified, this function will be called with three arguments. It has the same
89+
signature as ```_handle_func_requests``, but may return ``None``.
90+
91+
Returns:
92+
FirebaseError: A ``FirebaseError`` that can be raised to the user code.
93+
"""
94+
if error.response is None:
95+
return handle_requests_error(error)
96+
97+
response = error.response
98+
content = response.content.decode()
99+
status_code = response.status_code
100+
error_dict, message = _parse_platform_error(content, status_code)
101+
exc = None
102+
if handle_func:
103+
exc = handle_func(error, message, error_dict)
104+
105+
return exc if exc else _handle_func_requests(error, message, error_dict)
106+
107+
108+
def _handle_func_requests(error, message, error_dict):
109+
"""Constructs a ``FirebaseError`` from the given GCP error.
110+
111+
Args:
112+
error: An error raised by the requests module while making an HTTP call.
113+
message: A message to be included in the resulting ``FirebaseError``.
114+
error_dict: Parsed GCP error response.
115+
116+
Returns:
117+
FirebaseError: A ``FirebaseError`` that can be raised to the user code or None.
118+
"""
119+
code = error_dict.get('status')
120+
return handle_requests_error(error, message, code)
121+
122+
123+
def handle_requests_error(error, message=None, code=None):
53124
"""Constructs a ``FirebaseError`` from the given requests error.
54125
126+
This method is agnostic of the remote service that produced the error, whether it is a GCP
127+
service or otherwise. Therefore, this method does not attempt to parse the error response in
128+
any way.
129+
55130
Args:
56-
error: An error raised by the reqests module while making an HTTP call.
131+
error: An error raised by the requests module while making an HTTP call.
57132
message: A message to be included in the resulting ``FirebaseError`` (optional). If not
58133
specified the string representation of the ``error`` argument is used as the message.
59-
status: An HTTP status code that will be used to determine the resulting error type
60-
(optional). If not specified the HTTP status code on the error response is used.
134+
code: A GCP error code that will be used to determine the resulting error type (optional).
135+
If not specified the HTTP status code on the error response is used to determine a
136+
suitable error code.
61137
62138
Returns:
63139
FirebaseError: A ``FirebaseError`` that can be raised to the user code.
@@ -75,9 +151,143 @@ def handle_requests_error(error, message=None, status=None):
75151
message='Unknown error while making a remote service call: {0}'.format(error),
76152
cause=error)
77153

78-
if not status:
79-
status = error.response.status_code
154+
if not code:
155+
code = _http_status_to_error_code(error.response.status_code)
80156
if not message:
81157
message = str(error)
82-
err_type = _STATUS_TO_EXCEPTION_TYPE.get(status, exceptions.UnknownError)
158+
159+
err_type = _error_code_to_exception_type(code)
83160
return err_type(message=message, cause=error, http_response=error.response)
161+
162+
163+
def handle_platform_error_from_googleapiclient(error, handle_func=None):
164+
"""Constructs a ``FirebaseError`` from the given googleapiclient error.
165+
166+
This can be used to handle errors returned by Google Cloud Platform (GCP) APIs.
167+
168+
Args:
169+
error: An error raised by the googleapiclient while making an HTTP call to a GCP API.
170+
handle_func: A function that can be used to handle platform errors in a custom way. When
171+
specified, this function will be called with three arguments. It has the same
172+
signature as ```_handle_func_googleapiclient``, but may return ``None``.
173+
174+
Returns:
175+
FirebaseError: A ``FirebaseError`` that can be raised to the user code.
176+
"""
177+
if not isinstance(error, googleapiclient.errors.HttpError):
178+
return handle_googleapiclient_error(error)
179+
180+
content = error.content.decode()
181+
status_code = error.resp.status
182+
error_dict, message = _parse_platform_error(content, status_code)
183+
http_response = _http_response_from_googleapiclient_error(error)
184+
exc = None
185+
if handle_func:
186+
exc = handle_func(error, message, error_dict, http_response)
187+
188+
return exc if exc else _handle_func_googleapiclient(error, message, error_dict, http_response)
189+
190+
191+
def _handle_func_googleapiclient(error, message, error_dict, http_response):
192+
"""Constructs a ``FirebaseError`` from the given GCP error.
193+
194+
Args:
195+
error: An error raised by the googleapiclient module while making an HTTP call.
196+
message: A message to be included in the resulting ``FirebaseError``.
197+
error_dict: Parsed GCP error response.
198+
http_response: A requests HTTP response object to associate with the exception.
199+
200+
Returns:
201+
FirebaseError: A ``FirebaseError`` that can be raised to the user code or None.
202+
"""
203+
code = error_dict.get('status')
204+
return handle_googleapiclient_error(error, message, code, http_response)
205+
206+
207+
def handle_googleapiclient_error(error, message=None, code=None, http_response=None):
208+
"""Constructs a ``FirebaseError`` from the given googleapiclient error.
209+
210+
This method is agnostic of the remote service that produced the error, whether it is a GCP
211+
service or otherwise. Therefore, this method does not attempt to parse the error response in
212+
any way.
213+
214+
Args:
215+
error: An error raised by the googleapiclient module while making an HTTP call.
216+
message: A message to be included in the resulting ``FirebaseError`` (optional). If not
217+
specified the string representation of the ``error`` argument is used as the message.
218+
code: A GCP error code that will be used to determine the resulting error type (optional).
219+
If not specified the HTTP status code on the error response is used to determine a
220+
suitable error code.
221+
http_response: A requests HTTP response object to associate with the exception (optional).
222+
If not specified, one will be created from the ``error``.
223+
224+
Returns:
225+
FirebaseError: A ``FirebaseError`` that can be raised to the user code.
226+
"""
227+
if isinstance(error, socket.timeout) or (
228+
isinstance(error, socket.error) and 'timed out' in str(error)):
229+
return exceptions.DeadlineExceededError(
230+
message='Timed out while making an API call: {0}'.format(error),
231+
cause=error)
232+
elif isinstance(error, httplib2.ServerNotFoundError):
233+
return exceptions.UnavailableError(
234+
message='Failed to establish a connection: {0}'.format(error),
235+
cause=error)
236+
elif not isinstance(error, googleapiclient.errors.HttpError):
237+
return exceptions.UnknownError(
238+
message='Unknown error while making a remote service call: {0}'.format(error),
239+
cause=error)
240+
241+
if not code:
242+
code = _http_status_to_error_code(error.resp.status)
243+
if not message:
244+
message = str(error)
245+
if not http_response:
246+
http_response = _http_response_from_googleapiclient_error(error)
247+
248+
err_type = _error_code_to_exception_type(code)
249+
return err_type(message=message, cause=error, http_response=http_response)
250+
251+
252+
def _http_response_from_googleapiclient_error(error):
253+
"""Creates a requests HTTP Response object from the given googleapiclient error."""
254+
resp = requests.models.Response()
255+
resp.raw = six.BytesIO(error.content)
256+
resp.status_code = error.resp.status
257+
return resp
258+
259+
260+
def _http_status_to_error_code(status):
261+
"""Maps an HTTP status to a platform error code."""
262+
return _HTTP_STATUS_TO_ERROR_CODE.get(status, exceptions.UNKNOWN)
263+
264+
265+
def _error_code_to_exception_type(code):
266+
"""Maps a platform error code to an exception type."""
267+
return _ERROR_CODE_TO_EXCEPTION_TYPE.get(code, exceptions.UnknownError)
268+
269+
270+
def _parse_platform_error(content, status_code):
271+
"""Parses an HTTP error response from a Google Cloud Platform API and extracts the error code
272+
and message fields.
273+
274+
Args:
275+
content: Decoded content of the response body.
276+
status_code: HTTP status code.
277+
278+
Returns:
279+
tuple: A tuple containing error code and message.
280+
"""
281+
data = {}
282+
try:
283+
parsed_body = json.loads(content)
284+
if isinstance(parsed_body, dict):
285+
data = parsed_body
286+
except ValueError:
287+
pass
288+
289+
error_dict = data.get('error', {})
290+
msg = error_dict.get('message')
291+
if not msg:
292+
msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format(status_code, content)
293+
return error_dict, msg

0 commit comments

Comments
 (0)