fix: 闸门 fail-closed 堵重启/Redis 故障窗口漏放

- _check_request_block: Redis 不可用/无限额/读取异常时监控条目恒 429(标记 -1,-1),非监控渠道不受影响;原实现任何异常 fail-open
- async_pre_call_hook: 识别 fail-closed 标记返回 quota unavailable 429
- FALLBACK_LIMIT 900k→200k(免费池 2M 的 10%),收窄 0 余额模型兜底泄露
- docker-compose: redis 开启 AOF(--appendonly yes everysec),防重启丢快照/用量
- active_monitor_loop: 首轮跳过 CHECK_INTERVAL,额度未同步空窗 12-20s 缩至 2-5s
This commit is contained in:
vibe-code
2026-08-13 09:18:23 +00:00
parent 4e98c14920
commit 9d46fd4aa5
3 changed files with 54 additions and 12 deletions
+3 -1
View File
@@ -50,7 +50,9 @@ services:
# --maxmemory-policy allkeys-lru : 内存满了之后,自动删除最久未使用的缓存(无论是否过期) # --maxmemory-policy allkeys-lru : 内存满了之后,自动删除最久未使用的缓存(无论是否过期)
# --save "" : 如果你不需要持久化缓存到硬盘(重启后清空),可以把 save 设为空,减少磁盘IO # --save "" : 如果你不需要持久化缓存到硬盘(重启后清空),可以把 save 设为空,减少磁盘IO
# command: redis-server --save 60 1 --maxmemory 128mb --maxmemory-policy allkeys-lru --loglevel warning # command: redis-server --save 60 1 --maxmemory 128mb --maxmemory-policy allkeys-lru --loglevel warning
command: redis-server --maxmemory 32mb --maxmemory-policy allkeys-lru --save 300 1 --loglevel warning # --appendonly yes: 开启 AOF,防止 Redis 崩溃/重启丢失快照与用量 key →
# 避免"额度未同步"fallback 窗口(首次同步前)被大请求烧穿免费额度
command: redis-server --maxmemory 32mb --maxmemory-policy allkeys-lru --save 300 1 --appendonly yes --appendfsync everysec --loglevel warning
environment: environment:
TZ: Asia/Shanghai TZ: Asia/Shanghai
# 配置详解: # 配置详解:
+1 -1
View File
@@ -196,7 +196,7 @@ HARD_DAILY_LIMITS → 统一 2M(采集上限;doubao-seed-code-preview-251028
- 消耗任务 23:20 烧(usage 计入):池不变 → 滞后窗口内限额偏松 ≤ BUFFER ✓ - 消耗任务 23:20 烧(usage 计入):池不变 → 滞后窗口内限额偏松 ≤ BUFFER ✓
- 余额耗尽:池 − 30 万 ≤ 0 → 限额 0 → 全拦,扣费结构性杜绝 ✓ - 余额耗尽:池 − 30 万 ≤ 0 → 限额 0 → 全拦,扣费结构性杜绝 ✓
**fallback(stable_limit 缺失窗口)**:免费模型余额常态 < 2M(昨日采多少返多少),若 dynamic 缺失(首次同步前/新增模型/TTL 过期)时裸奔到静态 2M,余额不足的模型会被烧穿扣费。故 fallback 用保守值 `FALLBACK_LIMIT = min(static, 90万)`(env 可调),宁可短时收紧不可裸奔。 **fallback(stable_limit 缺失窗口)**:免费模型余额常态 < 2M(昨日采多少返多少),若 dynamic 缺失(首次同步前/新增模型/TTL 过期)时裸奔到静态 2M,余额不足的模型会被烧穿扣费。故 fallback 用保守值 `FALLBACK_LIMIT = min(static, 20万)`(env 可调;2M 免费池的 10%,0 余额模型最大泄露收窄到 20 万),宁可短时收紧不可裸奔。另:Redis 不可用/读取异常时请求闸门 fail-closed(直接 429),不再放行。
**与消耗任务同构**:限额放行条件 = `balance − BUFFER ≥ 0`;消耗任务停止条件 = `余额快照 − 已烧 ≤ 0`——同一把尺子。 **与消耗任务同构**:限额放行条件 = `balance − BUFFER ≥ 0`;消耗任务停止条件 = `余额快照 − 已烧 ≤ 0`——同一把尺子。
+50 -10
View File
@@ -109,10 +109,12 @@ PREBLOCK_RESERVE_TOKENS = int(os.getenv("PREBLOCK_RESERVE_TOKENS", "50000"))
# max(声明值, 本下限) 兜住思考开销;2M 限额下 4096 偏差仅 0.2%。 # max(声明值, 本下限) 兜住思考开销;2M 限额下 4096 偏差仅 0.2%。
THINKING_RESERVE_FLOOR = int(os.getenv("THINKING_RESERVE_FLOOR", "4096")) THINKING_RESERVE_FLOOR = int(os.getenv("THINKING_RESERVE_FLOOR", "4096"))
# stable_limit 缺失时(首次同步前/新增模型/TTL 过期)的保守兜底限额。 # stable_limit 缺失时(首次同步前/新增模型/TTL 过期/Redis 数据丢失)的保守兜底限额。
# 免费模型余额常态 < 2M(昨日采多少返多少),若 fallback 裸奔到采集上限 2M, # 免费池常态 ~2M(首开赠送/昨日采多少返多少),若 fallback 裸奔到采集上限 2M,
# 余额不足的模型会被烧穿扣费。宁可短时收紧,不可裸奔。 # 余额不足或已耗尽的模型会被烧穿扣费。900k 对 0 余额模型仍是 9 倍优惠,
FALLBACK_LIMIT = int(os.getenv("FALLBACK_LIMIT", "900000")) # 收紧到 20 万(2M 的 10%,单次消耗语料 5~11 万不受影响):宁可短时错杀大请求,
# 不可漏放大烧;快照由守护线程恢复后立即回到真实限额。
FALLBACK_LIMIT = int(os.getenv("FALLBACK_LIMIT", "200000"))
# Redis Config # Redis Config
REDIS_HOST = os.getenv( REDIS_HOST = os.getenv(
@@ -857,9 +859,11 @@ def _check_request_block(model_name, reserve=0.0):
只拦截 MONITORED_MODEL_PAIRS 内的火山监控条目;newapi 等非监控渠道天然放行。 只拦截 MONITORED_MODEL_PAIRS 内的火山监控条目;newapi 等非监控渠道天然放行。
reserve: 本次请求的预估消耗(max_tokens 或常量兜底),usage + reserve >= limit 即拦截, reserve: 本次请求的预估消耗(max_tokens 或常量兜底),usage + reserve >= limit 即拦截,
避免并发请求在闸门检查后集体撞线(限额是低频快照,用量是实时累计)。 避免并发请求在闸门检查后集体撞线(限额是低频快照,用量是实时累计)。
返回 (clean_model, usage, limit) 表示超限,None 表示放行。任何异常 fail-open。 返回 (clean_model, usage, limit) 表示超限,None 表示放行。
fail-closed:Redis 不可用 / 限额获取失败 / 读取异常时,对监控条目返回
(clean_model, -1, -1) 标记拒绝(宁可错杀,不可漏放);非监控条目不受影响。
""" """
if not redis_client or not LITELLM_AVAILABLE: if not LITELLM_AVAILABLE:
return None return None
try: try:
from litellm.proxy.proxy_server import llm_router from litellm.proxy.proxy_server import llm_router
@@ -889,9 +893,24 @@ def _check_request_block(model_name, reserve=0.0):
if MONITORED_MODEL_PAIRS and (clean, h) not in MONITORED_MODEL_PAIRS: if MONITORED_MODEL_PAIRS and (clean, h) not in MONITORED_MODEL_PAIRS:
continue continue
# ── 监控条目 fail-closed:Redis 不可用直接拒绝 (usage=-1, limit=-1) ──
# 快照缺失但 Redis 可达时走 get_effective_limit 内部 fallback(收紧限额),
# 不在此拒绝——那是"尚未同步"的正常启动态,守护线程几分钟内补上。
if not redis_client:
log(
f"🚫 Redis unavailable, fail-closed 429 for monitored {clean}",
level="WARNING",
)
return (clean, -1, -1)
effective_limit = get_effective_limit(clean, api_base) effective_limit = get_effective_limit(clean, api_base)
if effective_limit is None: if effective_limit is None:
continue # 监控条目取不到限额:拒绝而非放行(fail-closed)
log(
f"🚫 No effective limit for monitored {clean}, fail-closed 429",
level="WARNING",
)
return (clean, -1, -1)
usage_key = get_usage_key(clean, api_base) usage_key = get_usage_key(clean, api_base)
val = redis_client.get(usage_key) val = redis_client.get(usage_key)
@@ -907,8 +926,11 @@ def _check_request_block(model_name, reserve=0.0):
return None return None
except Exception as e: except Exception as e:
log(f"❌ Pre-block check failed: {e}", level="ERROR") # fail-closed:无法确认额度状态即拒绝(调用方 429)。
return None # 到达此处时必已命中监控条目(非监控条目在循环内 continue 跳过,
# 不触发 Redis 调用),不会误伤 newapi 等非监控渠道。
log(f"❌ Pre-block check failed (fail-closed 429): {e}", level="ERROR")
return (model_name, -1, -1)
def quota_interceptor(kwargs, user_api_key_dict=None): def quota_interceptor(kwargs, user_api_key_dict=None):
@@ -1000,6 +1022,18 @@ class DailyLimitHandler(CustomLogger):
return None return None
clean, usage, limit = blocked clean, usage, limit = blocked
if usage < 0 or limit < 0:
# fail-closed 标记:Redis 不可用/限额状态未知,拒绝防漏放
log(
f"🚫 [PRE-BLOCK] {data['model']} denied: quota state unavailable (fail-closed)",
level="WARNING",
)
raise RateLimitError(
message=f"Quota state unavailable for model '{data['model']}', request denied to prevent overspend",
llm_provider="volcengine",
model=data["model"],
)
log( log(
f"🚫 [PRE-BLOCK] {data['model']} daily limit reached ({int(usage):,}+{int(reserve):,}/{int(limit):,})", f"🚫 [PRE-BLOCK] {data['model']} daily limit reached ({int(usage):,}+{int(reserve):,}/{int(limit):,})",
level="WARNING", level="WARNING",
@@ -1464,9 +1498,15 @@ def active_monitor_loop():
) )
print(f"SmallBuffer / 缓冲: {SMALL_BUFFER}", flush=True) print(f"SmallBuffer / 缓冲: {SMALL_BUFFER}", flush=True)
first_iteration = True
while True: while True:
try: try:
time.sleep(CHECK_INTERVAL) if first_iteration:
# 首轮跳过 CHECK_INTERVAL,紧接 startup_audit(sleep 2s) 后立即同步,
# 把"额度未同步"空窗从 ~12-20s 缩到 ~2-5s(仅剩拉包 API 往返)
first_iteration = False
else:
time.sleep(CHECK_INTERVAL)
_maybe_trigger_daily_consume() _maybe_trigger_daily_consume()
now = time.time() now = time.time()