AutoTeam-F / src /autoteam /mail /fallback.py
ZRainbow's picture
feat(round-12 S2): mail provider fallback chain + addy.io + simplelogin
5041525
Raw
History Blame Contribute Delete
12.3 kB
"""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
@property
def current_provider_name(self) -> str | None:
"""当前正在使用(或最近一次成功使用)的 provider name。"""
return self._current_name
@property
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",
]