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:
+3
-1
@@ -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
|
||||||
# 配置详解:
|
# 配置详解:
|
||||||
|
|||||||
@@ -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`——同一把尺子。
|
||||||
|
|
||||||
|
|||||||
+49
-9
@@ -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,8 +1498,14 @@ 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:
|
||||||
|
if first_iteration:
|
||||||
|
# 首轮跳过 CHECK_INTERVAL,紧接 startup_audit(sleep 2s) 后立即同步,
|
||||||
|
# 把"额度未同步"空窗从 ~12-20s 缩到 ~2-5s(仅剩拉包 API 往返)
|
||||||
|
first_iteration = False
|
||||||
|
else:
|
||||||
time.sleep(CHECK_INTERVAL)
|
time.sleep(CHECK_INTERVAL)
|
||||||
_maybe_trigger_daily_consume()
|
_maybe_trigger_daily_consume()
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
Reference in New Issue
Block a user