Foreshock
EN 订阅
文档

接入

fail-closed 客户端契约

裁决只会出现在 200 里。其余一切——每个错误、每次超时、任意一侧的每次故障——都是 HOLD,而落实这一条是你的客户端的职责,不亚于是我们的。

更新于
2026-08-24

这一页决定这次接入到底值不值得做。本文档其余每一页都默认你已经实现了这上面的内容。

规则一句话说得完。

裁决只会出现在 200 响应体里。任何 5xx、任何连接错误、任何 TLS 或 DNS 失败、以及你这一侧的任何超时,都意味着裁决不存在——你的客户端必须按 HOLD 处理。

两个失败面,只有一个在我们这边

一笔入金的裁决出问题有两种方式,处理它们的地方完全不同。

裁决内部的降级。 供应商超时、预算花完、名单陈旧、注册条目未按时重新核验、作用域内某权威不可观测、裁决本身超时。这些仍然产生一条裁决。它们以 HTTP 200 + verdict: HOLD 加一个 reason code 的形式回来。你不需要识别它们,它们本身就是答案。

条件裁决Reason codeHTTP
供应商超时或不可用HOLDSCREENING_DEGRADED200
客户日预算耗尽HOLDBUDGET_EXHAUSTED200
某必需名单超出其新鲜度目标HOLDLIST_STALE200
某注册条目未按时重新核验HOLDREGISTRY_ENTRY_STALE200
本笔入金作用域内某权威不可观测HOLDCOVERAGE_BLIND_SPOT200
裁决未能在其时延预算内完成HOLDDECISION_TIMEOUT200

根本没有裁决。 我们不可用、请求没到我们这里,或者响应没回到你那里。没有裁决可读。这一类活在你的代码里,我们写什么契约都替你执行不了。

发生了什么你收到什么你必须做什么
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、409Problem挂起。这些是 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 生成,所以契约变动时这些夹具仍然是对的。

#注入的条件期望的客户端行为
1200,verdict: CREDIT,无降级贷记。唯一贷记的一行。
2200,verdict: HOLD,SCREENING_DEGRADED挂起。安排恢复后重新裁决。
3200,verdict: HOLD,BUDGET_EXHAUSTED挂起。对预算告警。
4200,verdict: HOLD,LIST_STALE挂起。
5200,verdict: HOLD,REGISTRY_ENTRY_STALE挂起。
6200,verdict: HOLD,COVERAGE_BLIND_SPOT挂起。记录盲区。
7200,verdict: HOLD,DECISION_TIMEOUT挂起。
8200,verdict: REJECT不贷记。按 pool 路由。
9200,不认识的 reason code挂起,严重程度至少等同 risk_level。
10200,Foreshock-Coverage-Health: degraded按裁决行动,并记录可见性下降。
11400 / 401 / 403 / 409挂起。告警。不盲目重试。
12429 带 Retry-After挂起。退避。等待期间不贷记。
13500 / 503 / 504挂起。用同一个幂等键重试。
14响应被扣住直到超过你的超时挂起。
15连接被拒挂起。
16TLS 握手失败挂起。
17DNS 解析失败挂起。
18200 但响应体被截断或损坏挂起。
19200 且 verdict: "MAYBE"挂起。
20同键同体连调两次只产生一条裁决。第二次带 Idempotency-Replayed: true。

然后写下那条比任何单行都重要的断言:

除第一行外的每一行,都断言这笔入金没有被贷记。

把它跑在 CI 里。贷记路径上的穷尽分支测试不是可选的覆盖率,它是「一个难受的下午」与「一个难受的季度」之间唯一的东西。以后每新增一条错误分支,都要新增自己的一行,断言 HOLD。

这换来什么

我们宕机时,你停止贷记。这是代价,而且正是你在贷记路径前面放一道闸门时选择的那个代价。

另一种做法——在故障中照常贷记——不是更小的代价。它是一个你以后才会知道的代价,计价单位是已经离开的资金。