"""Retry Snowflake reads when a Chartmetric share table is briefly missing. Chartmetric's shared tables (``CHARTMETRIC.RAW_DATA.*``) temporarily disappear and reappear (typically ~5 minutes later). A read during that window fails with a ``42S02`` SQL compilation error (``errno`` 2003). Because it is a compilation error, nothing is executed, so the statement is safe to retry. This module retries only that specific transient case, with bounded backoff, and lets every other error propagate immediately. """ import functools import logging import time from snowflake.connector.errors import ProgrammingError logger = logging.getLogger(__name__) # Chartmetric share is a fixed database name, referenced literally in the flow # SQL (e.g. `chartmetric.raw_data.itunes`), so it is environment-independent. CHARTMETRIC_SHARE_PREFIX = 'CHARTMETRIC.RAW_DATA.' # Bounded retry: delays 5, 10, 20, 40, 80, 120, 120 -> ~6.5 min total, which is # comfortably past the observed ~5 min reappearance window. MAX_ATTEMPTS = 8 INITIAL_DELAY = 5 MAX_DELAY = 120 def is_transient_share_miss(exc): """Return True only for a missing Chartmetric share table. Args: exc (Exception): The exception raised by a Snowflake call. Returns: bool: True if `exc` is a `CHARTMETRIC.RAW_DATA. does not exist` compilation error (errno 2003), False otherwise. """ return ( isinstance(exc, ProgrammingError) and exc.errno == 2003 and CHARTMETRIC_SHARE_PREFIX in (exc.msg or '').upper() ) def _reconnect(executor): """Reopen the Snowflake connection if it was closed by a failed statement. The base executor's `get_cursor()` closes the whole connection on any error, so a retry would otherwise hit a dead connection ('Connection is closed'). This restores a live connection before the next attempt. Args: executor (SnowflakeSQLExecutor): The executor instance to reconnect. """ conn = getattr(executor, 'snowflake_conn', None) if conn is None or conn.is_closed(): executor.snowflake_conn = executor.get_connection() def retry_on_missing_share_table(method): """Retry a Snowflake executor method while a share table is absent. Retries with exponential backoff when the wrapped call fails because a `CHARTMETRIC.RAW_DATA.*` table is transiently missing, reopening the connection between attempts. Any other error (including a genuinely missing table in our own schema) propagates on the first occurrence. Args: method (callable): The bound executor method to wrap. Its first positional argument is the executor instance (`self`). Returns: callable: The wrapped method. """ @functools.wraps(method) def wrapper(self, *args, **kwargs): delay = INITIAL_DELAY for attempt in range(1, MAX_ATTEMPTS + 1): try: return method(self, *args, **kwargs) except ProgrammingError as exc: if not is_transient_share_miss(exc): raise if attempt == MAX_ATTEMPTS: logger.error( f'Chartmetric share table still unavailable after ' f'{MAX_ATTEMPTS} attempts; giving up ({exc.msg}).') raise logger.warning( f'Chartmetric share table unavailable ({exc.msg}). ' f'Retry {attempt}/{MAX_ATTEMPTS} in {delay}s.') time.sleep(delay) delay = min(delay * 2, MAX_DELAY) _reconnect(self) return wrapper class ChartmetricShareRetryMixin: """Add Chartmetric share-table retry to a SnowflakeSQLExecutor subclass. Mix in *before* SnowflakeSQLExecutor so that `super()` resolves to the base implementation of each read method. """ @retry_on_missing_share_table def execute(self, *args, **kwargs): """Execute an SQL statement, retrying transient share-table misses.""" return super().execute(*args, **kwargs) @retry_on_missing_share_table def fetchone(self, *args, **kwargs): """Fetch one row, retrying transient share-table misses.""" return super().fetchone(*args, **kwargs) @retry_on_missing_share_table def fetchall(self, *args, **kwargs): """Fetch all rows, retrying transient share-table misses.""" return super().fetchall(*args, **kwargs)