import functools import itertools import sys import traceback from collections import defaultdict from typing import Any, Dict, List, TypeVar import exponent_server_sdk as expo from requests.exceptions import ConnectionError, HTTPError, ProxyError from smelog.factory import BoundLogger from send_push_messages.config import Config as config from send_push_messages.constants import MULTIPLE_ORGANIZATIONS_ERROR_CODE, PushMessageErrors from send_push_messages.exceptions import MultipleProjectsError from send_push_messages.utils import retry ExponentPushToken = TypeVar("ExponentPushToken") def split_messages_by_tokens( messages: List[expo.PushMessage], token_groups: Dict[Any, List[ExponentPushToken]] ) -> List[List[expo.PushMessage]]: """Split messages by passed tokens groups for multiple organizations error case.""" _token_groups = [set(group) for group in token_groups.values()] group_to_messages = defaultdict(list) for m in messages: for i, group in enumerate(_token_groups): if m.to in group: group_to_messages[i].append(m) break return list(group_to_messages.values()) def log_and_parse_error(exc: Exception, retry_number: int, *args, **kwargs): """Log errors between retries. Raise MultipleProjectsError in multiple organizations case (because we don't need retry in this case). """ client = args[0] err = ( f"{retry_number}/{config.EXPO_RETRY_COUNT} {client.__class__.__name__} " f"call got {exc} with args: {args}, kwargs: {kwargs}. " ) if isinstance(exc, expo.PushServerError): err += f"{exc.message}. Response {exc.response}: {exc.errors}.\n" f"Response data: {exc.response_data}.\n" for _err in exc.errors or []: error_code = _err.get("code") if error_code == MULTIPLE_ORGANIZATIONS_ERROR_CODE: client.logger.warning(err) raise MultipleProjectsError(token_groups=_err.get("details", {})) client.logger.error(err + f"{traceback.format_exception(*sys.exc_info()) + traceback.format_stack()}") def handle_expo_errors(f): """Resolve knows Expo errors. 1.'PUSH_TOO_MANY_EXPERIENCE_IDS' errors related to the case when token of multiple organizations appear to be in one chunk. Split these messages by projects depending on the Expo error details and send them by separated requests. 2. For one-message chunk Expo will raise instead returning receipt with error. Handle this case and create receipt manually based on the data from exception. """ @functools.wraps(f) def decorated(client, messages, **kwargs): try: return f(client, messages, **kwargs) except MultipleProjectsError as ex: messages_per_projects = split_messages_by_tokens(messages, ex.token_groups) receipts = [receipt for group in messages_per_projects for receipt in f(client, group, **kwargs)] return receipts except expo.PushServerError as ex: if len(messages) == 1: return [ expo.PushTicket( push_message=messages[0], status=expo.PushTicket.ERROR_STATUS, message="Expo error.", details=f"{PushMessageErrors.EXPO_ERROR}. {str(ex.errors)}", ) ] else: raise return decorated class PushClient: """Push client for delivering push messages to devices.""" def __init__(self, provider: expo.PushClient, logger: BoundLogger): self.provider = provider self.logger = logger @handle_expo_errors @retry( config.EXPO_RETRY_COUNT, config.EXPO_RETRY_DELAY, excepted=(expo.PushServerError, ConnectionError, HTTPError, ProxyError), exc_handler=log_and_parse_error, ) def send(self, messages: List[expo.PushMessage]) -> List[expo.PushTicket]: return self.provider.publish_multiple(messages) def send_by_chunks( self, messages: List[expo.PushMessage], chunk_size: int = config.MESSAGES_CHUNK_SIZE ) -> List[expo.PushTicket]: """Send messages to Expo in chunks. Using chunks in necessary because of Expos chunk size restriction.""" return list( itertools.chain(*[self.send(messages[i : i + chunk_size]) for i in range(0, len(messages), chunk_size)]) )