fix: Router.Unavailable -> all keys short-cooldown 30s + cooldown error shows cause/expiry

This commit is contained in:
hmo
2026-08-04 11:09:27 +08:00
parent a83660ebd3
commit 745daaa1a2
+35 -8
View File
@@ -42,7 +42,8 @@ USAGE_STATS_FILE = _GATEWAY_DIR / "temp" / "usage_stats.json"
ACCOUNTS_FILE = _SCRIPT_DIR / "usage_monitor" / "accounts.json" ACCOUNTS_FILE = _SCRIPT_DIR / "usage_monitor" / "accounts.json"
# 故障切换:同一个 key 失败后冷却时间(秒) # 故障切换:同一个 key 失败后冷却时间(秒)
FAIL_COOLDOWN_SEC = 300 # 5 分钟 FAIL_COOLDOWN_SEC = 300 # 5 分钟key 自身故障)
UPSTREAM_COOLDOWN_SEC = 30 # 上游模型级故障(Router.Unavailable)短冷却:换 key 无意义
# 用量数据刷新间隔 # 用量数据刷新间隔
USAGE_REFRESH_INTERVAL = 120 # 2 分钟 USAGE_REFRESH_INTERVAL = 120 # 2 分钟
# 最大重试次数(所有 key 耗尽) # 最大重试次数(所有 key 耗尽)
@@ -65,6 +66,7 @@ log = logging.getLogger("ocg_router")
_keys: list[dict] = [] # [{key_id, label, api_key, workspace_id}] _keys: list[dict] = [] # [{key_id, label, api_key, workspace_id}]
_key_usage: dict[str, dict] = {} # key_id → {rolling_pct, weekly_pct, monthly_pct} _key_usage: dict[str, dict] = {} # key_id → {rolling_pct, weekly_pct, monthly_pct}
_key_failures: dict[str, float] = {} # key_id → fail_until_timestamp _key_failures: dict[str, float] = {} # key_id → fail_until_timestamp
_key_failure_reason: dict[str, str] = {} # key_id → 最近失败原因(冷却跳过时用于报错)
_route_stats: dict[str, int] = {} # key_id → hit_count _route_stats: dict[str, int] = {} # key_id → hit_count
_total_hits = 0 _total_hits = 0
_current_key_id = "" _current_key_id = ""
@@ -200,13 +202,15 @@ def pick_key():
return best return best
def mark_key_failed(kid): def mark_key_failed(kid, reason="", cooldown=FAIL_COOLDOWN_SEC):
"""标记 key 故障,进入冷却期。""" """标记 key 故障,进入冷却期。记录失败原因供冷却期报错。"""
until = time.time() + FAIL_COOLDOWN_SEC until = time.time() + cooldown
with _state_lock: with _state_lock:
_key_failures[kid] = until _key_failures[kid] = until
log.warning("key %s marked failed until %s", kid, if reason:
datetime.fromtimestamp(until).strftime("%H:%M:%S")) _key_failure_reason[kid] = reason
log.warning("key %s marked failed until %s (%ds): %s", kid,
datetime.fromtimestamp(until).strftime("%H:%M:%S"), cooldown, reason)
def record_route(kid): def record_route(kid):
@@ -332,6 +336,7 @@ class RouterHandler(BaseHTTPRequestHandler):
"monthly_pct": usage.get("monthly_pct", None), "monthly_pct": usage.get("monthly_pct", None),
"healthy": usage.get("subscribed", True) and (time.time() >= fail_until), "healthy": usage.get("subscribed", True) and (time.time() >= fail_until),
"in_cooldown": time.time() < fail_until, "in_cooldown": time.time() < fail_until,
"fail_reason": _key_failure_reason.get(kid, ""),
"error": usage.get("error", False), "error": usage.get("error", False),
"hits": _route_stats.get(kid, 0), "hits": _route_stats.get(kid, 0),
"last_update": usage.get("last_update", ""), "last_update": usage.get("last_update", ""),
@@ -459,14 +464,35 @@ class RouterHandler(BaseHTTPRequestHandler):
# 401(未授权/CreditsError)/402/403/429/5xx → 标记 key 故障 # 401(未授权/CreditsError)/402/403/429/5xx → 标记 key 故障
status = result.get("status", 0) status = result.get("status", 0)
if status in (401, 402, 403, 429) or status >= 500: if status in (401, 402, 403, 429) or status >= 500:
mark_key_failed(kid) if "Router.Unavailable" in last_error:
# 上游模型级故障:换 key 无意义 → 全体短冷却并立即失败返回
with _state_lock:
until = time.time() + UPSTREAM_COOLDOWN_SEC
for k2 in _keys:
_key_failures[k2["key_id"]] = until
_key_failure_reason[k2["key_id"]] = last_error
log.warning("upstream Router.Unavailable → all keys short-cooldown %ds", UPSTREAM_COOLDOWN_SEC)
success = False
break
mark_key_failed(kid, last_error)
except Exception as e: except Exception as e:
last_error = str(e) last_error = str(e)
log.warning("%s via %s exception: %s", model_name, kid, e) log.warning("%s via %s exception: %s", model_name, kid, e)
mark_key_failed(kid) mark_key_failed(kid, last_error)
if not success: if not success:
elapsed = time.time() - start elapsed = time.time() - start
if not last_error:
# 所有 key 在冷却中被跳过 → 组装原因+最早到期时间
now = time.time()
cooling = [(kid, until) for kid, until in failures_snapshot.items() if now < until]
if cooling:
earliest = min(until for _, until in cooling)
reasons = {_key_failure_reason.get(kid, "unknown") for kid, _ in cooling}
reason_str = "; ".join(sorted(r for r in reasons if r and r != "unknown"))
last_error = (f"all keys in cooldown until "
f"{datetime.fromtimestamp(earliest).strftime('%H:%M:%S')}"
f"{' (' + reason_str + ')' if reason_str else ''}")
log.error("%s ALL KEYS EXHAUSTED (%.2fs): %s", model_name, elapsed, last_error) log.error("%s ALL KEYS EXHAUSTED (%.2fs): %s", model_name, elapsed, last_error)
self._error_response(502, f"All OCG keys exhausted: {last_error}") self._error_response(502, f"All OCG keys exhausted: {last_error}")
@@ -597,6 +623,7 @@ def _bg_refresh():
expired = [k for k, v in _key_failures.items() if now >= v] expired = [k for k, v in _key_failures.items() if now >= v]
for k in expired: for k in expired:
del _key_failures[k] del _key_failures[k]
_key_failure_reason.pop(k, None)
log.info("key %s cooldown expired, restored", k) log.info("key %s cooldown expired, restored", k)
log.info("bg refresh: %d keys, healthy pool", len(_keys)) log.info("bg refresh: %d keys, healthy pool", len(_keys))
except Exception as e: except Exception as e: