接入
fail-closed 客户端契约
裁决只会出现在 200 里。其余一切——每个错误、每次超时、任意一侧的每次故障——都是 HOLD,而落实这一条是你的客户端的职责,不亚于是我们的。
这一页决定这次接入到底值不值得做。本文档其余每一页都默认你已经实现了这上面的内容。
规则一句话说得完。
裁决只会出现在 200 响应体里。任何 5xx、任何连接错误、任何 TLS 或 DNS 失败、以及你这一侧的任何超时,都意味着裁决不存在——你的客户端必须按
HOLD处理。
两个失败面,只有一个在我们这边
一笔入金的裁决出问题有两种方式,处理它们的地方完全不同。
裁决内部的降级。 供应商超时、预算花完、名单陈旧、注册条目未按时重新核验、作用域内某权威不可观测、裁决本身超时。这些仍然产生一条裁决。它们以 HTTP 200 + verdict: HOLD 加一个 reason code 的形式回来。你不需要识别它们,它们本身就是答案。
| 条件 | 裁决 | Reason code | HTTP |
|---|---|---|---|
| 供应商超时或不可用 | HOLD | SCREENING_DEGRADED | 200 |
| 客户日预算耗尽 | HOLD | BUDGET_EXHAUSTED | 200 |
| 某必需名单超出其新鲜度目标 | HOLD | LIST_STALE | 200 |
| 某注册条目未按时重新核验 | HOLD | REGISTRY_ENTRY_STALE | 200 |
| 本笔入金作用域内某权威不可观测 | HOLD | COVERAGE_BLIND_SPOT | 200 |
| 裁决未能在其时延预算内完成 | HOLD | DECISION_TIMEOUT | 200 |
根本没有裁决。 我们不可用、请求没到我们这里,或者响应没回到你那里。没有裁决可读。这一类活在你的代码里,我们写什么契约都替你执行不了。
| 发生了什么 | 你收到什么 | 你必须做什么 |
|---|---|---|
500 内部错误 | Problem,fail_closed_action: HOLD | 挂起。 |
503 不可用 | Problem,fail_closed_action: HOLD | 挂起。 |
504 网关超时 | Problem,fail_closed_action: HOLD | 挂起。 |
429 限流 | Problem,fail_closed_action: HOLD | 挂起。退避。 |
400、401、403、409 | Problem | 挂起。这些是 bug 或权限问题,不是裁决。 |
| 你的客户端超时 | 什么都没有 | 挂起。 |
| 连接被拒、TLS 失败、DNS 失败 | 什么都没有 | 挂起。 |
| 200 但响应体解析不了 | 垃圾 | 挂起。 |
fail_closed_action 是这条义务的机器可读重述,不是裁决。它唯一定义的取值是 HOLD。它不出现——例如传输层失败、根本没有响应时——不改变任何事。
为什么我方故障是 5xx 而不是 200
我们本可以在自己这边宕机时返回 200 + verdict: HOLD。我们刻意不这么做。
那样一来,一个忽略状态码、只读响应体的客户端就无法区分「一次故障」与「一次裁决」。把故障做成 5xx,意味着一个粗糙的客户端会响亮地失败,而不是悄悄地把我们的沉默当成一个经过思考的答复。
官方 SDK 怎么做
官方 SDK 把 5xx-as-HOLD 作为默认行为实现,且不可通过配置关闭。没有开关、没有构造参数、没有环境变量能关掉它。如果你需要别的行为,你就在受支持的接入方式之外了;之所以不留开关,是因为开关正是凌晨三点故障中被扳动的那个东西。
客户端超时、连接错误、解析不了的响应体,都在同一处以同样的方式处理。
如果你自己写客户端
把这些分支都实现。一个不落。
function decide(deposit):
try:
r := POST /v1/decide/deposit
body: deposit
headers: Idempotency-Key = stable_key_for(deposit.deposit_ref)
timeout: your_budget
catch timeout, connection_error, tls_error, dns_error:
return HOLD # 不存在裁决
if r.status != 200:
return HOLD # 含 4xx:那也不是裁决
body := parse_json(r)
if parse failed or body.verdict not in {CREDIT, HOLD, REJECT}:
return HOLD # 形状不认识不等于许可
if r.header["Foreshock-Coverage-Health"] == "degraded":
record_blind_spot(deposit) # 裁决仍然成立;记下来
return body.verdict
注意这个函数从不做的事:它没有任何一条路径能在没有一个写着 CREDIT 的、已解析的 200 响应体的情况下返回 CREDIT。把函数写成 CREDIT 只能从其中恰好一行产生。这样评审时那个问题——这条分支有没有可能返回 CREDIT?——的答案,读一眼就有。
反模式
下面每一条都有人在生产环境里、有意地实现过。
- 重试到 2xx 然后贷记。 重试是对的。因为重试终于不报错了、却不读响应体里的裁决就贷记,不对。
- 超时就贷记,「应该没事」。 超时恰恰是真实故障最常见的形状。
- 给「Foreshock 挂了」准备一条本地放行规则。 这就是 fail-open,只是多绕了几步。它把我们的故障转换成你的损失。
- 把 4xx 当裁决。
403不是REJECT,400不是CREDIT。两者都不携带裁决。 - 把
REVOKED当作无风险。 它只陈述一条匹配离开了你的活跃集,仅此而已。 - 把
STALE当作安全。 它陈述的是数据已不可信。回退到更保守的策略。 - 忽略
Foreshock-Coverage-Health: degraded。 裁决仍然有效,但你被告知可见性下降了。记下来。 - 先贷记、事后回滚。 回滚不是控制措施,是道歉。
测试每一种降级模式
你没法对着一个健康的服务验证 fail-closed 行为。把客户端指向一个你自己控制的桩,把整个矩阵跑一遍。契约测试与桩都可以由 api/openapi.yaml 生成,所以契约变动时这些夹具仍然是对的。
| # | 注入的条件 | 期望的客户端行为 |
|---|---|---|
| 1 | 200,verdict: CREDIT,无降级 | 贷记。唯一贷记的一行。 |
| 2 | 200,verdict: HOLD,SCREENING_DEGRADED | 挂起。安排恢复后重新裁决。 |
| 3 | 200,verdict: HOLD,BUDGET_EXHAUSTED | 挂起。对预算告警。 |
| 4 | 200,verdict: HOLD,LIST_STALE | 挂起。 |
| 5 | 200,verdict: HOLD,REGISTRY_ENTRY_STALE | 挂起。 |
| 6 | 200,verdict: HOLD,COVERAGE_BLIND_SPOT | 挂起。记录盲区。 |
| 7 | 200,verdict: HOLD,DECISION_TIMEOUT | 挂起。 |
| 8 | 200,verdict: REJECT | 不贷记。按 pool 路由。 |
| 9 | 200,不认识的 reason code | 挂起,严重程度至少等同 risk_level。 |
| 10 | 200,Foreshock-Coverage-Health: degraded | 按裁决行动,并记录可见性下降。 |
| 11 | 400 / 401 / 403 / 409 | 挂起。告警。不盲目重试。 |
| 12 | 429 带 Retry-After | 挂起。退避。等待期间不贷记。 |
| 13 | 500 / 503 / 504 | 挂起。用同一个幂等键重试。 |
| 14 | 响应被扣住直到超过你的超时 | 挂起。 |
| 15 | 连接被拒 | 挂起。 |
| 16 | TLS 握手失败 | 挂起。 |
| 17 | DNS 解析失败 | 挂起。 |
| 18 | 200 但响应体被截断或损坏 | 挂起。 |
| 19 | 200 且 verdict: "MAYBE" | 挂起。 |
| 20 | 同键同体连调两次 | 只产生一条裁决。第二次带 Idempotency-Replayed: true。 |
然后写下那条比任何单行都重要的断言:
除第一行外的每一行,都断言这笔入金没有被贷记。
把它跑在 CI 里。贷记路径上的穷尽分支测试不是可选的覆盖率,它是「一个难受的下午」与「一个难受的季度」之间唯一的东西。以后每新增一条错误分支,都要新增自己的一行,断言 HOLD。
这换来什么
我们宕机时,你停止贷记。这是代价,而且正是你在贷记路径前面放一道闸门时选择的那个代价。
另一种做法——在故障中照常贷记——不是更小的代价。它是一个你以后才会知道的代价,计价单位是已经离开的资金。