""" Exception taxonomy for Gmail integration. gmail_client._call() maps HTTP status codes to these exception types. The retry loop in gmail_client._with_retry() inspects the class hierarchy to decide whether to back off + retry or fail fast. """ class GmailError(Exception): """Base class for all Gmail-integration errors.""" def __init__(self, message: str = "", *, status: int = 0, payload: object = None): super().__init__(message) self.status = status self.payload = payload class AuthError(GmailError): """401 / 403 that is not a rate-limit. Requires operator intervention (bad service account key, revoked OAuth, missing DWD scope). Not retried.""" class RateLimitError(GmailError): """429 or 403 with reason in {rateLimitExceeded, userRateLimitExceeded}. Retried with exponential backoff.""" class TransientError(GmailError): """5xx or network error. Retried with exponential backoff.""" class NotFoundError(GmailError): """404. For messages this usually means 'deleted in Gmail after we saw it'; for history this is HistoryExpiredError.""" class HistoryExpiredError(NotFoundError): """404 on history.list with startHistoryId — Gmail only retains history for a limited window (~7 days). Triggers date-based backfill fallback.""" class PermanentError(GmailError): """400 or other permanent failure. Skip and log; do not retry.""" def classify_http(status: int, payload: object) -> GmailError: """Map a Gmail API response to the appropriate exception type. `payload` is the decoded JSON body if any; used to distinguish rate-limit 403s from pure auth 403s via the `reason` field Google returns. """ reason = "" if isinstance(payload, dict): try: errs = payload.get("error", {}).get("errors") or [] if errs: reason = str(errs[0].get("reason", "")) except Exception: # pragma: no cover — defensive pass if status == 429: return RateLimitError(f"rate limited: {reason}", status=status, payload=payload) if status == 403: if reason in ("rateLimitExceeded", "userRateLimitExceeded", "quotaExceeded"): return RateLimitError(f"quota: {reason}", status=status, payload=payload) return AuthError(f"forbidden: {reason}", status=status, payload=payload) if status == 401: return AuthError("unauthorized", status=status, payload=payload) if status == 404: return NotFoundError("not found", status=status, payload=payload) if 500 <= status < 600: return TransientError(f"server error {status}", status=status, payload=payload) if 400 <= status < 500: return PermanentError(f"client error {status}: {reason}", status=status, payload=payload) return GmailError(f"unexpected status {status}", status=status, payload=payload) RETRYABLE = (RateLimitError, TransientError)