from __future__ import annotations import json import re from dataclasses import dataclass from datetime import datetime, timedelta, timezone from utils.helper import UpstreamHTTPError @dataclass(frozen=True) class UploadLimit: blocked_until: datetime reason: str class UploadUnavailableError(RuntimeError): def __init__(self, blocked_until: datetime | None = None, *, attempts_exhausted: bool = False): message = ( "多个账号上传受限,本次自动切换已达到上限,请稍后重试。" if attempts_exhausted else "当前没有可用于参考图上传的账号,请等待上传额度恢复或添加可用账号。" ) if blocked_until: message += f" 已知上传限制最早预计恢复时间:{blocked_until.astimezone().strftime('%Y-%m-%d %H:%M:%S %Z')}。" super().__init__(message) def parse_upload_limit(error: Exception, now: datetime | None = None) -> UploadLimit | None: """Recognize file quota failures only at the upstream upload allocation endpoint.""" if not isinstance(error, UpstreamHTTPError) or error.status_code != 429 or error.context != "/backend-api/files": return None body = error.body if isinstance(body, str): try: body = json.loads(body) except (ValueError, TypeError): return None if not isinstance(body, dict): return None detail = body.get("detail") or body.get("error") or body if not isinstance(detail, dict): return None reason = str(detail.get("message") or "文件上传额度已用尽") codes = {str(detail.get(key) or "").lower() for key in ("code", "error_code", "type")} if not codes.intersection({"throttled", "file_upload_limit_exceeded", "upload_limit_exceeded"}) and not ( "上传上限" in reason or "upload limit" in reason.lower() ): return None now = now or datetime.now(timezone.utc) for key in ("retry_at", "reset_at"): value = detail.get(key) if value: try: reset = datetime.fromisoformat(str(value).replace("Z", "+00:00")) if reset.tzinfo and reset > now: return UploadLimit(reset, reason) except (ValueError, TypeError): pass if error.retry_after is not None and error.retry_after > 0: seconds = min(error.retry_after, 365 * 86400) else: units = {"小时": 3600, "分钟": 60, "秒": 1, "天": 86400, "day": 86400, "hour": 3600, "minute": 60, "second": 1} matches = re.findall(r"(\d+(?:\.\d+)?)\s*(小时|分钟|秒|天|days?|hours?|minutes?|seconds?)", reason.lower()) seconds = sum(float(number) * units[unit.rstrip("s")] for number, unit in matches) # Unknown reset time: temporary cooling, never a fabricated daily reset. seconds = min(seconds, 365 * 86400) if seconds > 0 else 15 * 60 return UploadLimit(now + timedelta(seconds=max(1, seconds)), reason)