朕喜技术(ZhenxiJS)
2026.10.14
1 分钟阅读

出站代理池 round_robin「挑中的代理 ≠ 登记的代理」+ 逐请求网络预算被全轮共享(Python / Docker 实战排查)

日期:2026-10-14 分支:2026-10-14-0003 期数:第 123 题 技术栈:Python 3.11 标准库 · Docker / docker compose · 契约驱动判定 · round_robin 轮询 · fail closed vs fail open · 逐请求网络预算 · 外部独立探针 · 退出码分层 关键词:出站代理池、egress proxy gateway、round_robin 轮询挑错代理、 RequestBudget 循环外实例化、全轮共享预算、blocked_by 只记第一个、空判据表、 健康检查假绿、退出码恒 0、fail-closed、契约驱动验收、budget_exhausted、attempts_exhausted

一、问题背景

一个出站代理池网关(srv/app,纯 Python 标准库)在一轮派发里,要对每条请求 「挑一个代理、按需重试、出回执报告」。契约 conf/proxy.env 规定 round_robin 策略、 逐请求网络预算 100ms、最多 4 次尝试;代理池 5 个代理里只有 3 个健康 (proxy-a/b/c 健康,proxy-d down、proxy-e unhealthy)。

现象:自述报告 ok:true、pool_health:"healthy",健康检查 {"degraded": false, "issues": []},三个脚本退出码全 0 —— 看着一切正常。

但外部独立探针 client/gateway_probe.py 报出真相:

不一致:r-002:期望生效代理 proxy-b 实际 proxy-a
不一致:r-004:期望生效代理 proxy-c 实际 proxy-a
不一致:r-005:期望 delivered(delivered) 实际 failed(budget_exhausted)
不一致:r-006:期望 delivered(delivered) 实际 failed(budget_exhausted)
RESULT: BAD(不一致 4 条)     probe rc=1

即:有一批请求被一个根本没参与轮询的代理「登记」了(挑中和登记是两条腿), 且前面几条请求把整轮的预算吃光后,后面的请求连一次尝试都做不了。

二、复现步骤

cd scenario/2026-10-14-0003
docker compose up -d --build
docker compose exec -T gateway bash

# 容器内
cd /srv
bash ops/repro.sh   ; echo "rc=$?"   # 问题态:探针 rc=1(预期信号,别用 && 串)
bash ops/verify.sh  ; echo "rc=$?"   # 问题态:PASS=9 FAIL=16 / RESULT: BAD
python3 client/gateway_probe.py      # 问题态:不一致 4 条,rc=1

无 docker 时(纯标准库直跑):

cd scenario/2026-10-14-0003
export SCENARIO_ROOT="$PWD" CONF_DIR="$PWD/conf" PYTHONPATH="$PWD"
bash ops/repro.sh ; echo "rc=$?"

问题态验收节选:

[FAIL] C2 r-002 生效代理(第 2 次尝试):期望 proxy-b 实际 proxy-a
[FAIL] C3 r-004 生效代理(第 3 次尝试):期望 proxy-c 实际 proxy-a
[FAIL] D1 r-005 判定:期望 delivered 实际 failed
[FAIL] D5 汇总 attempts_total:期望 10 实际 7
[FAIL] G2 契约不可用 health 退出码:期望 3 实际 0
[FAIL] H1 blocked_by 去重升序:期望 ['attempts_exhausted','budget_exhausted'] 实际 ['attempts_exhausted']
PASS=9 FAIL=16 / RESULT: BAD

三、根本原因

① 挑代理与登记代理是两条腿(题眼)app/selector.py::book() 登记「本次生效代理」时返回的是 pool[0](池子定义顺序第一个), 而不是传进来的 proxy。choose() 明明挑中了正确的代理,报告里写下的却是另一个。 所以 r-002 该是 proxy-b、r-004 该是 proxy-c,报告里全成了 proxy-a。

② 逐请求预算被做成全轮共享(题眼)app/gateway.py 里 RequestBudget(...) 在 for req in requests: 循环外只 new 了一次, 5 条请求共用同一本账;判预算时用 budget_ms - req_budget.elapsed_ms,那是「全轮还剩多少」, 不是「这个请求还剩多少」。于是前 3 条请求把 100ms 吃光,r-005/r-006 直接 budget_exhausted。「前面好好的、后面全崩」就是这个顺序性指纹。

③ blocked_by 只记第一个原因 全池失败时循环 append 第一个就 break,并发失败的现场只剩一个原因。

④ 把「候选集非空」当成「代理健康」choose() 里 health_state = "healthy" if proxy is not None else "down", 池级健康态口径错了。

⑤ 一层「假绿」app/health.py 的 BAD_FIELDS = () 是空判据表 —— for 一次都不循环, issues 恒空、degraded 恒 false;main() 又无条件 return EXIT_OK, 连「读不到报告」都当健康;contract.py 读不到契约还静默回退默认值。 这叫「给自己发体检合格证」:问题态它照样全绿,只有外部探针说真话。

四、解决办法

只改 app/ 下 4 个文件(conf/、docs/、client/、input/ 是只读考卷,一字未动)。

app/selector.py

def book(pool, proxy):
    """登记「本次真正生效的代理」—— 也就是刚被挑中的那一个。"""
    return proxy
# 池级健康态由「池里有没有健康代理」决定,与「这次挑没挑到」无关
health_state = "healthy" if proxies.healthy_proxies(pool) else "down"

app/gateway.py(逐请求独立预算 + fail closed + 去重 blocked_by + 透出 budget_ms)

conf = contract.load_contract()
if not conf.get("contract_available"):
    raise RuntimeError("契约不可用:%s" % conf.get("contract_problem"))   # fail closed

results = []
for req in requests:
    # 逐请求独立预算 —— 每个请求一本账,互不影响
    req_budget = budget.RequestBudget(int(req.get("budget_ms", conf["request_budget_ms"])))
    ...
    cost = proxies.probe_ms(proxy)
    if not req_budget.can_spend(cost):        # 判的是「本请求」剩余额度
        reason = "budget_exhausted"
        break
    booked = selector.book(pool, proxy)       # 挑中谁就登记谁
    proxy_hit = booked["id"] if booked else None
    req_budget.spend(cost)
    ...

# 全有或全无:列出每一个不同的失败原因(去重 + 升序)
blocked_by = []
if delivered == 0 and results:
    blocked_by = sorted({r["reason"] for r in results if r["verdict"] != "delivered"})

app/contract.py:契约读不到 / selection 非法 / 数值字段缺失或非法 → contract_available=False,不再静默回退默认值。

app/health.py:BAD_FIELDS 换成非空判据表(contract / pool_health / item.proxy / item.attempts_detail / item.elapsed_ms / blocked_by / aggregate),每条对应一个外部能独立 复核的事实;健康检查按契约独立重算(_reference() 与 gateway.py 分开实现,避免自己治自己); 退出码分层 0 干净 / 2 有真问题或拿不到事实 / 3 契约不可用,「判不了」按「有问题」处理。

五、验证

docker compose exec -T gateway bash -c 'cd /srv && bash ops/repro.sh; echo "rc=$?"'
{"attempts_total": 10, "contract_available": true, "delivered": 6, "failed": 0,
 "ok": true, "pool_health": "healthy", "selection": "round_robin"}
健康检查:{"degraded": false, ..., "attempts_total": 10, "delivered": 6, "failed": 0}  health rc=0
RESULT: OK      probe rc=0
docker compose exec -T gateway bash -c 'cd /srv && bash ops/verify.sh'
# PASS=25 FAIL=0 / RESULT: OK
docker compose exec -T gateway bash -c 'cd /srv && bash ops/negative.sh'
# 负向验证:11 PASS / 0 FAIL / RESULT: OK(闸门有牙)

负向验证证明闸门「真的有牙」,不是「恰好绿」:把题眼回滚(book() 返回 pool[0]、 预算改全轮共享、blocked_by 只记一个)后探针全部转红;篡改自述 delivered=999 后 health 退出非 0;把判据表挖空后对坏数据仍返回 0 —— 实证「假绿」; 契约不可用 / 口径非法时 health 返回 3(fail closed)。

六、经验与避坑

  • 挑代理与登记代理必须是同一条腿:任何「挑中 A、报告里写 B」的实现,都会让报告失去 复核价值。拿 attempts_detail[0].proxy 与顶层 proxy 对一下,一眼见分晓。
  • 判预算时先问一句「这个 remaining 属于谁」:RequestBudget 该在循环内 new。 「前面请求成功、后面请求全崩」的顺序性失败,就是共享额度的指纹。
  • 空判据表 = 没有检查:BAD_FIELDS = () 时 for 循环体一次都不执行, 代码看着像有检查,其实是空转。判据要有内容,且必须是外部能自己算一遍的事实。
  • 自述字段不能自证:「拿自己的字段比自己」永远绿。要有一份外部独立探针/检查, 按只读考卷(契约 + 输入事实 + 报告)独立重算。
  • 「判不了」≠「没问题」:契约不可用、报告读不到,都要判不通过(fail closed), 别把「静默放行」当健壮。退出码分层 0/2/3 便于上层区分。
  • 排查时别信 ok / pool_health / degraded,直接跑外部探针看退出码。
  • 两个脚本小坑:ops/repro.sh 以探针退出码为退出码(问题态 1,不要用 && 串); ops/negative.sh 收尾会 apply_reference,把参考解覆盖到 app/ —— 若你保留了自研修复,跑完负向验证记得拷回。

一句话总结:报告里的 proxy 必须就是实际生效的那个代理;预算是逐请求的; 判据表要非空且外部可复核;「判不了」按「有问题」处理。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-14-0003

分享这篇文章