出站代理池 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 必须就是实际生效的那个代理;预算是逐请求的;
判据表要非空且外部可复核;「判不了」按「有问题」处理。
