fix(oauth): ACCOUNT_BLOCK 标记不再禁用 key,token 刷新成功自动解除所有 invalid 标记

- error_handler 标记 ACCOUNT_BLOCK 时保持 is_active=True,oauth_invalid 标记已
  足够阻止调度,配额刷新仍可覆盖该 key
- 移除 is_account_level_block 守卫:token 刷新成功即证明账号可用,清除所有
  oauth_invalid 标记(含 ACCOUNT_BLOCK)
- health_policy 401 瞬时失败不再设置 60s cooldown,仅清除 token 缓存后立即重试
- key_quota_service 全量刷新时纳入 ACCOUNT_BLOCK 的 key,账号恢复后可自动解除
- 各 refresher 刷新成功时显式恢复 is_active=True
This commit is contained in:
fawney19
2026-03-16 15:50:41 +08:00
parent 791c9c98dc
commit 8cd3a69803
9 changed files with 36 additions and 21 deletions

View File

@@ -79,8 +79,6 @@ def _store_refreshed_oauth_sync(
access_token: str,
parsed_auth_config: dict[str, Any],
) -> None:
from src.services.provider.oauth_token import is_account_level_block
with get_db_context() as db:
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key:
@@ -88,10 +86,12 @@ def _store_refreshed_oauth_sync(
key.api_key = crypto_service.encrypt(access_token)
key.auth_config = crypto_service.encrypt(json.dumps(parsed_auth_config))
if not is_account_level_block(getattr(key, "oauth_invalid_reason", None)):
# 刷新成功 => 清除所有 oauth_invalid 标记(包括 [ACCOUNT_BLOCK])。
# Token 能成功刷新说明账号可用,之前的 block 标记应视为过时。
if getattr(key, "oauth_invalid_at", None) is not None:
key.oauth_invalid_at = None
key.oauth_invalid_reason = None
key.is_active = True
key.is_active = True
# ==============================================================================

View File

@@ -407,7 +407,8 @@ class ErrorHandlerService:
key.oauth_invalid_at = datetime.now(timezone.utc)
key.oauth_invalid_reason = f"{OAUTH_ACCOUNT_BLOCK_PREFIX}{reason}"
key.is_active = False
# 不设 is_active=Falseoauth_invalid 标记已足够阻止调度,
# 保持 is_active=True 使配额刷新仍能覆盖该 key账号恢复后可自动解除。
pool_cfg = parse_pool_config(getattr(provider, "config", None))
auto_remove_enabled = bool(pool_cfg and pool_cfg.auto_remove_banned_keys)

View File

@@ -101,13 +101,11 @@ def _persist_refreshed_token(
key.api_key = crypto_service.encrypt(access_token)
key.auth_config = crypto_service.encrypt(json.dumps(token_meta))
# 刷新成功 => 清除非账号级别的 oauth_invalid 标记(如 [REFRESH_FAILED]
from src.services.provider.oauth_token import is_account_level_block
if not is_account_level_block(getattr(key, "oauth_invalid_reason", None)):
if getattr(key, "oauth_invalid_at", None) is not None:
key.oauth_invalid_at = None
key.oauth_invalid_reason = None
# 刷新成功 => 清除所有 oauth_invalid 标记(包括 [ACCOUNT_BLOCK]
# Token 能成功刷新说明账号可用,之前的 block 标记应视为过时。
if getattr(key, "oauth_invalid_at", None) is not None:
key.oauth_invalid_at = None
key.oauth_invalid_reason = None
sess = _safe_object_session(key)
if sess is not None:

View File

@@ -135,12 +135,13 @@ async def resolve_oauth_access_token(
if row is not None:
row.api_key = key_obj.api_key
row.auth_config = key_obj.auth_config
# Refresh succeeded => clear token-level invalid markers.
# Preserve account-level blocks (刷新 token 无法修复).
if not is_account_level_block(getattr(row, "oauth_invalid_reason", None)):
# Refresh succeeded => clear all invalid markers (including
# account-level blocks). A successful token refresh proves the
# account is usable; stale block marks should not persist.
if row.oauth_invalid_at is not None:
row.oauth_invalid_at = None
row.oauth_invalid_reason = None
row.is_active = True
row.is_active = True
db.commit()
except Exception as e:
# Don't fail caller path; token is still usable for this request.

View File

@@ -4,7 +4,7 @@ Maps upstream HTTP status codes to pool-level actions:
| Code | Action |
|--------------|------------------------------------------------------------|
| 401 | Invalidate OAuth token cache; permanent (deactivated) 1h, else 60s |
| 401 | Invalidate OAuth token cache; permanent (deactivated) 1h, else no cooldown |
| 402 | Long cooldown (payment issue) |
| 403 | Graded cooldown: severe (suspended/banned) 1h, else 300s+ |
| 400 | Check body for "organization has been disabled" -> cooldown |
@@ -183,10 +183,11 @@ async def _apply(
key_id[:8],
)
else:
# Transient auth failure (e.g. expired token) — short cooldown.
await redis_ops.set_cooldown(provider_id, key_id, "auth_failed_401", ttl=60)
# Transient auth failure (e.g. expired token) — token cache already
# invalidated above; the next request will trigger a token refresh.
# No cooldown needed: the key should be retried immediately after refresh.
logger.info(
"Pool[{}]: key {} got 401, token cache invalidated + 60s cooldown",
"Pool[{}]: key {} got 401, token cache invalidated (no cooldown)",
provider_id[:8],
key_id[:8],
)

View File

@@ -6,6 +6,7 @@ import asyncio
from collections.abc import Awaitable, Callable
from typing import Any
from sqlalchemy import or_ as db_or
from sqlalchemy.orm import Session, defer
from src.core.exceptions import InvalidRequestException, NotFoundException
@@ -114,7 +115,16 @@ async def refresh_provider_quota_for_provider(
)
)
if selected_key_ids is None:
keys_query = keys_query.filter(ProviderAPIKey.is_active.is_(True))
# 全量刷新:活跃 key + 被系统自动标记 ACCOUNT_BLOCK 的 key。
# 后者使 ACCOUNT_BLOCK 标记的 key 也参与刷新,账号恢复后可自动解除。
# 注意:不能用宽泛的 oauth_invalid_reason IS NOT NULL否则会纳入
# 用户手动停用 (is_active=False) 但恰好也有 reason 的历史 key。
keys_query = keys_query.filter(
db_or(
ProviderAPIKey.is_active.is_(True),
ProviderAPIKey.oauth_invalid_reason.startswith("[ACCOUNT_BLOCK]"),
)
)
else:
if not selected_key_ids:
return {

View File

@@ -114,6 +114,7 @@ async def refresh_antigravity_key_quota(
state_updates[key.id] = {
"oauth_invalid_at": None,
"oauth_invalid_reason": None,
"is_active": True,
}
return {
"key_id": key.id,

View File

@@ -280,6 +280,7 @@ async def refresh_codex_key_quota(
state_updates[key.id] = {
"oauth_invalid_at": None,
"oauth_invalid_reason": None,
"is_active": True,
}
return {
"key_id": key.id,
@@ -351,6 +352,7 @@ async def refresh_codex_key_quota(
state_updates[key.id] = {
"oauth_invalid_at": None,
"oauth_invalid_reason": None,
"is_active": True,
}
return {
"key_id": key.id,

View File

@@ -137,6 +137,7 @@ async def refresh_kiro_key_quota(
state_updates[key.id] = {
"oauth_invalid_at": None,
"oauth_invalid_reason": None,
"is_active": True,
}
# 如果 auth_config 有更新(例如 token 刷新),也需要更新