import itertools import functools import sys import time 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 from smelog.factory import BoundLogger from send_push_notifications.config import Config as config from send_push_notifications.constants import MESSAGES_CHUNK_SIZE, MULTIPLE_ORGANIZATIONS_ERROR_CODE from send_push_notifications.exceptions import MultipleProjectsError 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.""" _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 retry(f): """Retry Expo call depending on the config parameters.""" @functools.wraps(f) def decorated(client, messages, **kwargs): counter = 0 while True: try: return f(client, messages, **kwargs) except (expo.PushServerError, ConnectionError, HTTPError) as ex: err = f"{client.__class__.__name__} got error {ex} on sending messages. " is_server_error = isinstance(ex, expo.PushServerError) if is_server_error: err = f"{client.__class__.__name__} got error {ex} on sending messages. " err += f"{ex.message}. Response {ex.response}: {ex.response_data}. Errors: {ex.errors}.\n" err += f"Messages: {messages}, kwargs: {kwargs}.\n" f"Retry {counter}/{config.RETRY_COUNT}.\n" f"{traceback.format_exception(*sys.exc_info()) + traceback.format_stack()}" if is_server_error: for _err in ex.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) if counter >= config.RETRY_COUNT: client.logger.info(f"Retry number is exceeded. Empty list wil be returned.") return [] counter += 1 time.sleep(config.TIMEOUT) return decorated def split_by_projects(f): """Resolve 'PUSH_TOO_MANY_EXPERIENCE_IDS' errors related to the case when token of multiple projects appear to be in one chunk. Split these messages by projects depending on the Expo error details and send them by separated requests. """ @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 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 @split_by_projects @retry def send_chunk(self, messages: List[expo.PushMessage]) -> List[expo.PushResponse]: for i in range(len(messages) - 1, -1, -1): if not self.provider.is_exponent_push_token(messages[i].to): self.logger.warning( f"{self.__class__.__name__}.send() got message with invalid type token {messages[i].to}, skipped.") messages.pop(i) if not messages: return [] receipts = self.provider.publish_multiple(messages) return receipts def send(self, messages: List[expo.PushMessage], chunk_size: int = MESSAGES_CHUNK_SIZE) -> List[expo.PushResponse]: """Send messages to Expo in chunks. Using chunks in necessary because of Expos chunk size restriction.""" return list(itertools.chain( *[self.send_chunk(messages[i:i + chunk_size]) for i in range(0, len(messages), chunk_size)]))