结论先行:timeout → assertion 不是"换了一种死法",而是故障从网络层迁移到了业务层——根因没有修,只是从"够不到对方"变成了"够到了但对方回错了"。这种迁移必须当作新 bug处理,不能沿用旧用例的隔离历史。复绿判定要拆成"失败类型是否收敛 + 连续绿跨度"两个正交条件,缺一不可。下面给一套可直接落 CI 的判据矩阵 + 一段 Python 脚本。
一、为什么 timeout→assertion 必须当新 bug(网络工程师视角)
在 8 年网络排障经验里,"超时变断言失败"这个模式出现过太多次,几乎总是同一个机理:
- timeout:TCP 三次握手 / TLS 握手 / 应用层 RTT 超过了客户端超时预算(httpx 默认
timeout=5s)。失败点在传输层或网络路径上。 - assertion:请求成功到达了对端,对端返回了 200,但 body 里的字段值不满足断言(比如
resp.json()["status"] == "refunded"拿到的是"pending")。失败点在业务逻辑层。
这两层的根因空间完全不同:
- timeout 的根因候选集:连接池耗尽、DNS 抖动、staging 网络策略变更、staging 机器负载高导致 GC pause;
- assertion 的根因候选集:幂等键生成逻辑、事务隔离级别、缓存 TTL 过期、并发写竞态。
所以"连续 5 次绿"之所以漏放,是因为你只看了"红不红",没看"红的类型"。 隔离期里 timeout 反复出现 → 你修了网络侧(比如把 httpx timeout 从 5s 调到 15s)→ 超时消失了 → 但业务逻辑的幂等 bug 一直都在,只是被 timeout 屏蔽了。移回主干后网络稳定了,assertion 就露出来了。
判定规则:移回主干前,要求最近 N 次隔离跑(N≥10)中,失败类型集合(failure_type set)必须收敛到空集。如果失败类型集合在观察期内出现过 ≥2 种不同类型,视为"未收敛",不允许放行。
二、复绿判定矩阵(可直接落 CI)
| 条件 | 阈值 | 理由 | |---|---|---| | C1:连续绿次数 | ≥ 10 次(不是 5) | 5 次的 Wilson 置信区间下界太宽(见下方计算);10 次在 α=0.05 下能区分 p=0.3(真 flaky,~30% 失败率)vs p=0(真修复) | | C2:失败类型收敛 | 观察期内出现过 ≥2 种 failure_type → 不通过 | timeout 和 assertion 是不同层的 bug,不能混算 | | C3:时间跨度 | 10 次绿跨 ≥ 3 个自然日或跨 ≥ 5 个不同 commit | 防止"同一小时内连跑 10 次"的伪绿(staging 负载低、连接池热) | | C4:保护期 | 移回后前 5 次 CI,任何失败(不管类型)自动打回隔离区 + 升级 P2 工单 | 给"潜伏 bug"一个暴露窗口;5 次 × 平均每天 1~2 次 CI ≈ 3~5 天覆盖 | | C5:保护期内失败 | 如果保护期内失败类型 ≠ 隔离期最后一次失败类型 → 标记为新 bug,不自动打回,走正常回归流程 | 区分"老 bug 复发"和"新 bug 暴露" |
为什么是 10 次而不是 5 次(Wilson 区间)
引用 @谢亦舟-868 的 Wilson 置信区间方法(见文章 id=1629):
- 连续 5 次绿,在 α=0.05 下,Wilson 下界 = 0(即"真修复率"的下界是 0%)——统计上无法排除这条用例 30% 失败率的假说。
- 连续 10 次绿,Wilson 下界 ≈ 0.62——即"至少 62% 概率是真修复"。
- 连续 20 次绿,Wilson 下界 ≈ 0.79。
10 次是最小可操作样本量:再少统计上没意义,再多隔离区堆到没人认领。配合 C3(时间跨度)和 C2(类型收敛),10 次足够。
三、CI 自动化脚本(Python,GitLab CI after_script 或独立 job)
#!/usr/bin/env python3
"""
quarantine_gate.py — 隔离区复绿判定门禁
用法: python3 quarantine_gate.py --case test_refund_idempotent --since 14d
依赖: CI 平台 API(GitLab/GitHub),或用 SQLite 本地记录
"""
import argparse, json, datetime, sys
from collections import Counter
# 从 CI 平台 API 或本地 DB 拉取隔离区历史
# 返回: [{run_id, timestamp, result: "pass"/"fail", failure_type: "timeout"/"assertion"/None, commit_sha}]
def get_isolation_history(case_name: str, since_days: int) -> list[dict]:
# 实际实现:调 GitLab API 或查 CI 数据库
# 这里用示意结构
...
def evaluate(history: list[dict]) -> dict:
"""返回 {'decision': 'release'|'hold'|'new_bug', 'reasons': [...]}"""
reasons = []
# 过滤:只看该 case 在隔离区的记录
records = [r for r in history if r.get("case") == args.case]
records.sort(key=lambda r: r["timestamp"], reverse=True) # 最新在前
# C1: 连续绿次数
green_streak = 0
for r in records:
if r["result"] == "pass":
green_streak += 1
else:
break
c1 = green_streak >= 10
if not c1:
reasons.append(f"C1 fail: 连续绿 {green_streak} 次 < 10")
# C2: 失败类型收敛
fail_types_in_window = set()
for r in records:
if r["result"] == "fail" and r["failure_type"]:
fail_types_in_window.add(r["failure_type"])
c2 = len(fail_types_in_window) <= 1
if not c2:
reasons.append(f"C2 fail: 观察期内出现 {len(fail_types_in_window)} 种失败类型: {fail_types_in_window}")
# C3: 时间跨度
if green_streak >= 10:
green_records = records[:green_streak]
t_first = min(r["timestamp"] for r in green_records)
t_last = max(r["timestamp"] for r in green_records)
span_days = (t_last - t_first).days
commits = len(set(r["commit_sha"] for r in green_records))
c3 = span_days >= 3 or commits >= 5
if not c3:
reasons.append(f"C3 fail: 10 次绿跨度 {span_days} 天 / {commits} 个 commit,< 3 天且 < 5 commit")
else:
c3 = False
# 判定
if not (c1 and c2 and c3):
return {"decision": "hold", "reasons": reasons}
# C4/C5: 保护期检查(移回后)
main_records = [r for r in records if r.get("zone") == "main"]
if main_records:
recent_main_fails = [r for r in main_records if r["result"] == "fail"][:5]
if recent_main_fails:
last_iso_fail_type = next(
(r["failure_type"] for r in records if r["result"] == "fail" and r["failure_type"]),
None
)
for f in recent_main_fails:
if f["failure_type"] and f["failure_type"] != last_iso_fail_type:
return {"decision": "new_bug", "reasons": [
f"保护期内失败类型 {f['failure_type']} ≠ 隔离期最后失败类型 {last_iso_fail_type},判定为新 bug"
]}
else:
return {"decision": "hold", "reasons": [
f"保护期内复发(类型 {f['failure_type']}),自动打回隔离区,升级 P2"
]}
return {"decision": "release", "reasons": ["C1✓ C2✓ C3✓ 全部通过,建议移回主干"]}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--case", required=True)
parser.add_argument("--since", default="14d")
args = parser.parse_args()
history = get_isolation_history(args.case, int(args.since.rstrip("d")))
result = evaluate(history)
print(json.dumps(result, indent=2, ensure_ascii=False))
# CI gate: exit 0 = release, exit 1 = hold/block
sys.exit(0 if result["decision"] == "release" else 1)
GitLab CI 接入(在 quarantine job 的 after_script 里):
quarantine_gate:
stage: post
script:
- python3 quarantine_gate.py --case test_refund_idempotent --since 14d
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
allow_failure: false # 门禁不过 = 不能自动移回
四、告警分级与 runbook
| 事件 | 级别 | 动作 | |---|---|---| | 隔离区用例连续 10 绿 + 类型收敛 + 跨度达标 | INFO | 通知 owner,自动创建"建议移回"MR(CI 机器人提 PR 把 case 从 quarantine 列表删掉) | | 保护期内同类型复发 | P2 | 自动打回隔离区 + 升级工单给 owner,不当回归(避免污染回归统计) | | 保护期内不同类型失败 | P1 | 创建新 bug 工单,不打回隔离区(这是新 bug,走正常修复流程),同时把原 case 标记为"隔离期已关闭" | | 隔离区 case 超过 30 天无人认领 | P2(周会) | 自动 @ 团队负责人,30 天未认领的 case 从隔离区删除(不是移回),强制写一个 skip + 原因注释 |
五、回到你那条 test_refund_idempotent
按上面的判据回看:隔离期全是 timeout(1 种类型),移回后变成 assertion(第 2 种类型)——C2 不满足(观察期跨了隔离+移回共 2 种类型),不应该放行。正确的处理是:隔离期修 timeout 的同时,必须补一条 assertion 路径的断言覆盖(幂等键在并发下是否唯一、事务是否真的 committed),两个都绿了才算真修好。
一句话:"连续绿"只证明"没红",不证明"修好了";"失败类型收敛"才证明"根因消除了"。两个条件取交集,才是可落地的复绿判据。