Skip to content

Migrating FCM Send APIs to the New Exceptions #297

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jun 20, 2019
32 changes: 32 additions & 0 deletions firebase_admin/_messaging_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@

import six

from firebase_admin import exceptions


class Message(object):
"""A message that can be sent via Firebase Cloud Messaging.
Expand Down Expand Up @@ -797,3 +799,33 @@ def default(self, obj): # pylint: disable=method-hidden
if target_count != 1:
raise ValueError('Exactly one of token, topic or condition must be specified.')
return result


class ThirdPartyAuthError(exceptions.UnauthenticatedError):
"""APNs certificate or web push auth key was invalid or missing."""

def __init__(self, message, cause=None, http_response=None):
exceptions.UnauthenticatedError.__init__(self, message, cause, http_response)


class QuotaExceededError(exceptions.ResourceExhaustedError):
"""Sending limit exceeded for the message target."""

def __init__(self, message, cause=None, http_response=None):
exceptions.ResourceExhaustedError.__init__(self, message, cause, http_response)


class SenderIdMismatchError(exceptions.PermissionDeniedError):
"""The authenticated sender ID is different from the sender ID for the registration token."""

def __init__(self, message, cause=None, http_response=None):
exceptions.PermissionDeniedError.__init__(self, message, cause, http_response)


class UnregisteredError(exceptions.NotFoundError):
"""App instance was unregistered from FCM.

This usually means that the token used is no longer valid and a new one must be used."""

def __init__(self, message, cause=None, http_response=None):
exceptions.NotFoundError.__init__(self, message, cause, http_response)
189 changes: 182 additions & 7 deletions firebase_admin/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,19 @@

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

import json
import socket

import googleapiclient
import httplib2
import requests
import six

import firebase_admin
from firebase_admin import exceptions


_STATUS_TO_EXCEPTION_TYPE = {
_ERROR_CODE_TO_EXCEPTION_TYPE = {
400: exceptions.InvalidArgumentError,
401: exceptions.UnauthenticatedError,
403: exceptions.PermissionDeniedError,
Expand All @@ -29,6 +35,34 @@
429: exceptions.ResourceExhaustedError,
500: exceptions.InternalError,
503: exceptions.UnavailableError,

exceptions.INVALID_ARGUMENT: exceptions.InvalidArgumentError,
exceptions.FAILED_PRECONDITION: exceptions.FailedPreconditionError,
exceptions.OUT_OF_RANGE: exceptions.OutOfRangeError,
exceptions.UNAUTHENTICATED: exceptions.UnauthenticatedError,
exceptions.PERMISSION_DENIED: exceptions.PermissionDeniedError,
exceptions.NOT_FOUND: exceptions.NotFoundError,
exceptions.ABORTED: exceptions.AbortedError,
exceptions.ALREADY_EXISTS: exceptions.AlreadyExistsError,
exceptions.RESOURCE_EXHAUSTED: exceptions.ResourceExhaustedError,
exceptions.CANCELLED: exceptions.CancelledError,
exceptions.DATA_LOSS: exceptions.DataLossError,
exceptions.UNKNOWN: exceptions.UnknownError,
exceptions.INTERNAL: exceptions.InternalError,
exceptions.UNAVAILABLE: exceptions.UnavailableError,
exceptions.DEADLINE_EXCEEDED: exceptions.DeadlineExceededError,
}


_HTTP_STATUS_TO_ERROR_CODE = {
400: exceptions.INVALID_ARGUMENT,
401: exceptions.UNAUTHENTICATED,
403: exceptions.PERMISSION_DENIED,
404: exceptions.NOT_FOUND,
409: exceptions.CONFLICT,
429: exceptions.RESOURCE_EXHAUSTED,
500: exceptions.INTERNAL,
503: exceptions.UNAVAILABLE,
}


Expand All @@ -45,19 +79,50 @@ def _get_initialized_app(app):
raise ValueError('Illegal app argument. Argument must be of type '
' firebase_admin.App, but given "{0}".'.format(type(app)))


def get_app_service(app, name, initializer):
app = _get_initialized_app(app)
return app._get_service(name, initializer) # pylint: disable=protected-access

def handle_requests_error(error, message=None, status=None):

def handle_platform_error_from_requests(error, handle_func=None):
"""Constructs a ``FirebaseError`` from the given requests error.

This can be used to handle errors returned by Google Cloud Platform (GCP) APIs.

Args:
error: An error raised by the reqests module while making an HTTP call to a GCP API.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/reqests/requests/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

handle_func: A function that can be used to handle platform errors in a custom way. When
specified, this function will be called with four arguments -- parsed error response,
error message, source exception from requests and the HTTP response object.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I'd prefer an oxford comma in there. :-) But more seriously, please make this a bulleted list so that it's easier to scan what the four arguments are.


Returns:
FirebaseError: A ``FirebaseError`` that can be raised to the user code.
"""
if error.response is None:
return handle_requests_error(error)

response = error.response
content = response.content.decode()
status_code = response.status_code
error_dict, code, message = _parse_platform_error(content, status_code)
exc = None
if handle_func:
exc = handle_func(error_dict, message, error, error.response)

return exc if exc else handle_requests_error(error, message, code)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If might be a little cleaner than all this if you can make handle_requests_error have the same format as handle_func. The advantages would be:

  1. You could have the handle_func default to handle_requests_error and not have any of this branching code, and
  2. It would be easier to document handle_func by pointing to handle_requests_error as an example implementation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've sort of done both, but not quite the way described in your comment. PTAL.



def handle_requests_error(error, message=None, code=None):
"""Constructs a ``FirebaseError`` from the given requests error.

Args:
error: An error raised by the reqests module while making an HTTP call.
message: A message to be included in the resulting ``FirebaseError`` (optional). If not
specified the string representation of the ``error`` argument is used as the message.
status: An HTTP status code that will be used to determine the resulting error type
(optional). If not specified the HTTP status code on the error response is used.
code: An HTTP status code or GCP error code that will be used to determine the resulting
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it still seems sketchy to me to mix types here. Would it be possible to make this one or the other?

It occurs to me that handle_platform_error_from_requests extracts code, which is then ignored if a handle_func is set. Maybe if handle_requests_error is a handle_func, then the code doesn't need to be passed in, and handle_requests_error could call _parse_platform_error itself?

I'm not sure if that's a good idea, but it's worth thinking about.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handle_requests_error is supposed to be service-agnostic. I expect to use it for non-OP errors as well (we already do so in the instance_id module, which is a non-OP endpoint). So I extracted the OP-specific parsing to a new helper method named _handle_func_requests and made its signature consistent with handle_func.

But I do still need some branching since we allow handle_func to return None.

error type (optional). If not specified the HTTP status code on the error response is
used.

Returns:
FirebaseError: A ``FirebaseError`` that can be raised to the user code.
Expand All @@ -75,9 +140,119 @@ def handle_requests_error(error, message=None, status=None):
message='Unknown error while making a remote service call: {0}'.format(error),
cause=error)

if not status:
status = error.response.status_code
if not code:
code = error.response.status_code
if not message:
message = str(error)
err_type = _STATUS_TO_EXCEPTION_TYPE.get(status, exceptions.UnknownError)

err_type = _lookup_error_type(code)
return err_type(message=message, cause=error, http_response=error.response)


def handle_platform_error_from_googleapiclient(error, handle_func=None):
"""Constructs a ``FirebaseError`` from the given googleapiclient error.

This can be used to handle errors returned by Google Cloud Platform (GCP) APIs.

Args:
error: An error raised by the googleapiclient while making an HTTP call to a GCP API.
handle_func: A function that can be used to handle platform errors in a custom way. When
specified, this function will be called with four arguments -- parsed error response,
error message, source exception from googleapiclient and a HTTP response object.

Returns:
FirebaseError: A ``FirebaseError`` that can be raised to the user code.
"""
if not isinstance(error, googleapiclient.errors.HttpError):
return handle_googleapiclient_error(error)

content = error.content.decode()
status_code = error.resp.status
error_dict, code, message = _parse_platform_error(content, status_code)
exc = None
if handle_func:
http_response = _http_response_from_googleapiclient_error(error)
exc = handle_func(error_dict, message, error, http_response)

return exc if exc else handle_googleapiclient_error(error, message, code)


def handle_googleapiclient_error(error, message=None, code=None):
"""Constructs a ``FirebaseError`` from the given googleapiclient error.

Args:
error: An error raised by the googleapiclient module while making an HTTP call.
message: A message to be included in the resulting ``FirebaseError`` (optional). If not
specified the string representation of the ``error`` argument is used as the message.
code: An HTTP status code or GCP error code that will be used to determine the resulting
error type (optional). If not specified the HTTP status code on the error response is
used.

Returns:
FirebaseError: A ``FirebaseError`` that can be raised to the user code.
"""
if isinstance(error, socket.timeout) or (
isinstance(error, socket.error) and 'timed out' in str(error)):
return exceptions.DeadlineExceededError(
message='Timed out while making an API call: {0}'.format(error),
cause=error)
elif isinstance(error, httplib2.ServerNotFoundError):
return exceptions.UnavailableError(
message='Failed to establish a connection: {0}'.format(error),
cause=error)
elif not isinstance(error, googleapiclient.errors.HttpError):
return exceptions.UnknownError(
message='Unknown error while making a remote service call: {0}'.format(error),
cause=error)

if not code:
code = error.resp.status
if not message:
message = str(error)

http_response = _http_response_from_googleapiclient_error(error)
err_type = _lookup_error_type(code)
return err_type(message=message, cause=error, http_response=http_response)


def _http_response_from_googleapiclient_error(error):
"""Creates a requests HTTP Response object from the given googleapiclient error."""
resp = requests.models.Response()
resp.raw = six.BytesIO(error.content)
resp.status_code = error.resp.status
return resp


def _lookup_error_type(code):
"""Maps an error code to an exception type."""
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please document what error code means here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

return _ERROR_CODE_TO_EXCEPTION_TYPE.get(code, exceptions.UnknownError)


def _parse_platform_error(content, status_code):
"""Parses an HTTP error response from a Google Cloud Platform API and extracts the error code
and message fields.

Args:
content: Decoded content of the response body.
status_code: HTTP status code.

Returns:
tuple: A tuple containing error code and message.
"""
data = {}
try:
parsed_body = json.loads(content)
if isinstance(parsed_body, dict):
data = parsed_body
except ValueError:
pass

error_dict = data.get('error', {})
code = error_dict.get('status')
if not code:
code = _HTTP_STATUS_TO_ERROR_CODE.get(status_code, exceptions.UNKNOWN)

msg = error_dict.get('message')
if not msg:
msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format(status_code, content)
return error_dict, code, msg
Loading