Spaces:
Paused
Paused
| """Fallback mail provider chain — Tier-priority failover. | |
| 设计要点(详见 `.trellis/tasks/05-11-s2-mail-provider-fallback-gmx/prd.md` Q3-Q5): | |
| 1. **Lazy factory**: 接收 `[(name, factory), ...]`,延迟构造。任一 provider `__init__` | |
| 抛 `MailProviderUnavailable`(配置缺失)时,fallback 链自动跳过该项,不污染 | |
| 失败计数 — 配置缺失是用户主动放弃,而非业务失败。 | |
| 2. **失败计数**: 状态文件 `mail_provider_state.json`(项目根),schema: | |
| {"provider_name": {"fail_count": int, "last_fail_ts": float}} | |
| 连续失败 ≥ MAIL_PROVIDER_MAX_FAILURES(默认 3)→ blocked。 | |
| 3. **Cooldown 自动重置**: 单 provider blocked 后,`last_fail_ts + cooldown_secs` | |
| (默认 24h)过后,下次访问时 lazy reset(读时检查 ts 差,自动清零)。 | |
| 4. **聚合异常**: 全部 provider 都失败 → 抛 `MailProviderChainExhausted`,内含 | |
| 每个 provider 的最后一条错误。 | |
| 5. **不抛只 warn 的方法**: alias forwarding 类 provider(addy_io / simplelogin) | |
| 的 read 方法返回 `[]` 不抛,因此 fallback 链对读路径不会过度降级。 | |
| 抛异常的方法(login / create_temp_email / delete_account)才会触发 failover。 | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| import os | |
| import threading | |
| import time | |
| from collections.abc import Callable | |
| from pathlib import Path | |
| from typing import Any | |
| from autoteam.mail.base import MailProvider | |
| from autoteam.textio import read_text, write_text | |
| logger = logging.getLogger(__name__) | |
| # ----------------------------------------------------------------- exceptions | |
| class MailProviderUnavailable(Exception): | |
| """provider 配置不完整(env 缺失等)— 静默跳过,不计入失败计数。""" | |
| class MailProviderChainExhausted(Exception): | |
| """fallback 链上所有 provider 都失败 — 业务层应直接放弃此操作。""" | |
| def __init__(self, message: str, errors: dict[str, str] | None = None): | |
| super().__init__(message) | |
| self.errors: dict[str, str] = errors or {} | |
| # ----------------------------------------------------------------- state file | |
| PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent | |
| DEFAULT_STATE_FILE = PROJECT_ROOT / "mail_provider_state.json" | |
| DEFAULT_MAX_FAILURES = 3 | |
| DEFAULT_COOLDOWN_SECS = 24 * 3600 | |
| class _FailureTracker: | |
| """JSON 文件持久化的 per-provider 失败计数器。 | |
| 并发: `_LOCK` 串行化所有读改写,与 `register_failures.py` 的 `_LOCK` | |
| 设计一致;单进程足够,多进程并发场景下文件锁由调用方负责。 | |
| """ | |
| def __init__( | |
| self, | |
| state_file: Path | str | None = None, | |
| max_failures: int = DEFAULT_MAX_FAILURES, | |
| cooldown_secs: int = DEFAULT_COOLDOWN_SECS, | |
| ): | |
| self.state_file: Path = Path(state_file) if state_file else DEFAULT_STATE_FILE | |
| self.max_failures = max(1, int(max_failures)) | |
| self.cooldown_secs = max(1, int(cooldown_secs)) | |
| self._lock = threading.Lock() | |
| def _load(self) -> dict[str, dict[str, Any]]: | |
| if not self.state_file.exists(): | |
| return {} | |
| try: | |
| raw = read_text(self.state_file).strip() | |
| if not raw: | |
| return {} | |
| data = json.loads(raw) | |
| return data if isinstance(data, dict) else {} | |
| except Exception as exc: | |
| logger.warning("[mail-fallback] 状态文件 %s 解析失败,重置为空: %s", self.state_file, exc) | |
| return {} | |
| def _save(self, data: dict[str, dict[str, Any]]) -> None: | |
| try: | |
| write_text(self.state_file, json.dumps(data, indent=2, ensure_ascii=False)) | |
| try: | |
| os.chmod(self.state_file, 0o666) | |
| except Exception: | |
| pass | |
| except Exception as exc: | |
| logger.error("[mail-fallback] 状态文件 %s 写入失败: %s", self.state_file, exc) | |
| def _is_cooldown_expired(self, entry: dict[str, Any], now: float) -> bool: | |
| last_ts = float(entry.get("last_fail_ts") or 0) | |
| return last_ts > 0 and (now - last_ts) >= self.cooldown_secs | |
| def is_blocked(self, name: str) -> bool: | |
| """检查 provider 是否处于 blocked 状态(连续失败超阈值且未过 cooldown)。""" | |
| with self._lock: | |
| data = self._load() | |
| entry = data.get(name) | |
| if not entry: | |
| return False | |
| now = time.time() | |
| if self._is_cooldown_expired(entry, now): | |
| data.pop(name, None) | |
| self._save(data) | |
| logger.info("[mail-fallback] provider=%s cooldown 已过,失败计数自动重置", name) | |
| return False | |
| fail_count = int(entry.get("fail_count") or 0) | |
| return fail_count >= self.max_failures | |
| def record_failure(self, name: str, error: str = "") -> int: | |
| """记录一次失败,返回当前累计失败次数。""" | |
| with self._lock: | |
| data = self._load() | |
| entry = data.get(name) or {} | |
| now = time.time() | |
| if self._is_cooldown_expired(entry, now): | |
| entry = {} | |
| fail_count = int(entry.get("fail_count") or 0) + 1 | |
| data[name] = { | |
| "fail_count": fail_count, | |
| "last_fail_ts": now, | |
| "last_error": (error or "")[:300], | |
| } | |
| self._save(data) | |
| logger.warning( | |
| "[mail-fallback] provider=%s 失败 %d/%d: %s", | |
| name, | |
| fail_count, | |
| self.max_failures, | |
| (error or "")[:120], | |
| ) | |
| return fail_count | |
| def record_success(self, name: str) -> None: | |
| """业务成功后重置失败计数。""" | |
| with self._lock: | |
| data = self._load() | |
| if name in data: | |
| data.pop(name, None) | |
| self._save(data) | |
| logger.info("[mail-fallback] provider=%s 业务成功,失败计数已重置", name) | |
| # ----------------------------------------------------------------- fallback chain | |
| _DISPATCH_METHODS_REQUIRE_FAILOVER = ( | |
| "login", | |
| "create_temp_email", | |
| "list_accounts", | |
| "delete_account", | |
| "search_emails_by_recipient", | |
| "list_emails", | |
| "delete_emails_for", | |
| "get_latest_emails", | |
| ) | |
| class FallbackMailProvider(MailProvider): | |
| """按优先级尝试一组 mail provider,失败自动降级到下一个。 | |
| Args: | |
| providers: `[(name, factory), ...]`。factory 是 `Callable[[], MailProvider]`。 | |
| factory 抛 `MailProviderUnavailable` 时跳过(不计失败计数); | |
| 抛其他 Exception 视为构造失败,计入失败计数。 | |
| tracker: 失败计数追踪器。None 时使用默认全局状态文件 + 默认阈值。 | |
| Usage: | |
| chain = FallbackMailProvider([ | |
| ("maillab", MaillabClient), | |
| ("addy_io", AddyIoClient), | |
| ("simplelogin", SimpleLoginClient), | |
| ("cf_temp_email", CfTempEmailClient), | |
| ]) | |
| chain.create_temp_email(prefix="autoteam") # 自动 dispatch | |
| """ | |
| provider_name = "fallback" | |
| def __init__( | |
| self, | |
| providers: list[tuple[str, Callable[[], MailProvider]]], | |
| tracker: _FailureTracker | None = None, | |
| ): | |
| if not providers: | |
| raise ValueError("FallbackMailProvider 至少需要一个 provider") | |
| self._providers: list[tuple[str, Callable[[], MailProvider]]] = list(providers) | |
| self._tracker = tracker or _FailureTracker() | |
| # name → 已实例化的 provider(lazy) | |
| self._instances: dict[str, MailProvider] = {} | |
| # 当前 active provider 名(供日志/UI 展示) | |
| self._current_name: str | None = None | |
| self._lock = threading.Lock() | |
| # ------------------------------------------------------------------- pub | |
| def current_provider_name(self) -> str | None: | |
| """当前正在使用(或最近一次成功使用)的 provider name。""" | |
| return self._current_name | |
| def configured_chain(self) -> list[str]: | |
| """链上所有 provider 名(按优先级)。""" | |
| return [name for name, _ in self._providers] | |
| # ------------------------------------------------------------------- private | |
| def _get_or_create(self, name: str, factory: Callable[[], MailProvider]) -> MailProvider | None: | |
| """返回 provider 实例;不可用(构造失败 / blocked)时返回 None。""" | |
| with self._lock: | |
| if name in self._instances: | |
| return self._instances[name] | |
| if self._tracker.is_blocked(name): | |
| logger.info("[mail-fallback] provider=%s 处于 blocked 状态,跳过", name) | |
| return None | |
| try: | |
| instance = factory() | |
| except MailProviderUnavailable as exc: | |
| logger.info("[mail-fallback] provider=%s 配置不可用,跳过: %s", name, exc) | |
| return None | |
| except Exception as exc: | |
| self._tracker.record_failure(name, f"__init__: {exc}") | |
| return None | |
| with self._lock: | |
| self._instances[name] = instance | |
| return instance | |
| def _dispatch(self, method_name: str, *args, **kwargs): | |
| """按优先级遍历 provider,首个成功的返回结果;全部失败抛聚合异常。""" | |
| errors: dict[str, str] = {} | |
| last_error: Exception | None = None | |
| for name, factory in self._providers: | |
| instance = self._get_or_create(name, factory) | |
| if instance is None: | |
| errors[name] = "unavailable_or_blocked" | |
| continue | |
| method = getattr(instance, method_name, None) | |
| if method is None or not callable(method): | |
| errors[name] = f"method {method_name} not implemented" | |
| continue | |
| try: | |
| result = method(*args, **kwargs) | |
| except Exception as exc: | |
| self._tracker.record_failure(name, f"{method_name}: {exc}") | |
| errors[name] = f"{type(exc).__name__}: {exc}" | |
| last_error = exc | |
| # drop instance — 下次重新 init,避开半坏状态 | |
| with self._lock: | |
| self._instances.pop(name, None) | |
| continue | |
| # 成功:重置失败计数 + 记录 active | |
| self._tracker.record_success(name) | |
| with self._lock: | |
| self._current_name = name | |
| return result | |
| # 全部失败 | |
| msg = f"mail provider chain exhausted ({method_name}): {errors}" | |
| logger.error("[mail-fallback] %s", msg) | |
| raise MailProviderChainExhausted(msg, errors=errors) from last_error | |
| # ------------------------------------------------------------------- ABC | |
| def login(self) -> str: | |
| return self._dispatch("login") | |
| def create_temp_email( | |
| self, prefix: str | None = None, domain: str | None = None | |
| ) -> tuple[int | str, str]: | |
| return self._dispatch("create_temp_email", prefix, domain) | |
| def list_accounts(self, size: int = 200) -> list[dict]: | |
| return self._dispatch("list_accounts", size) | |
| def delete_account(self, account_id: int | str) -> dict: | |
| return self._dispatch("delete_account", account_id) | |
| def search_emails_by_recipient( | |
| self, to_email: str, size: int = 10, account_id: int | str | None = None | |
| ) -> list[dict]: | |
| return self._dispatch("search_emails_by_recipient", to_email, size, account_id) | |
| def list_emails(self, account_id: int | str, size: int = 10) -> list[dict]: | |
| return self._dispatch("list_emails", account_id, size) | |
| def delete_emails_for(self, to_email: str) -> int: | |
| return self._dispatch("delete_emails_for", to_email) | |
| def get_latest_emails( | |
| self, account_id: int | str, email_id: int = 0, all_receive: int = 0 | |
| ) -> list[dict]: | |
| return self._dispatch("get_latest_emails", account_id, email_id, all_receive) | |
| __all__ = [ | |
| "DEFAULT_COOLDOWN_SECS", | |
| "DEFAULT_MAX_FAILURES", | |
| "DEFAULT_STATE_FILE", | |
| "FallbackMailProvider", | |
| "MailProviderChainExhausted", | |
| "MailProviderUnavailable", | |
| "_FailureTracker", | |
| ] | |