Spaces:
Paused
Paused
File size: 12,282 Bytes
5041525 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 | """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",
]
|