# 防止代理覆盖的条件式 API 请求

代理可能生成完全有效的 API 请求，却仍然造成真正的损害。问题出现在这样的流程中：代理读取一条记录，另一个参与者修改了记录，代理随后又把旧副本写回去，覆盖了更新后的状态。身份验证无法阻止这一点，授权也一样。请求确实来自获准的主体，但其中带着一个已经过时的现实视图。

条件式 API 请求正好解决这种失败。客户端实际上是在说：“只有当资源仍然是我观察到的那个版本时，才应用这项变更。”服务器会把这句话作为写入的一部分进行检查。如果条件为假，服务器会在做出任何改变之前拒绝操作。

与人点击表单相比，这种契约对编程代理更加重要。代理可以读取许多资源，暂停下来检查代码或运行测试，然后在外部世界已经变化后批量写入。除非 API 能证明某个操作是追加操作或可交换命令，否则应把每次有实际影响的更新都视为一次读改写操作。

## 普通的读改写流程也会发生更新丢失

当两个写入者从同一个旧状态开始，而后写入的那个操作抹去了较早的变更时，就发生了更新丢失。这不需要数据库宕机、恶意用户或网络故障。只要服务器接受无条件替换，就可能发生。

假设有一个以 JSON 形式提供的部署配置：

```json
{
  "name": "billing-worker",
  "replicas": 3,
  "image": "registry.example/billing:2.4.0",
  "maintenanceMode": false
}
```

代理读取配置，准备在负载测试前把 `replicas` 从 3 增加到 5。代理工作期间，一名操作员为了调查队列问题，把 `maintenanceMode` 改成了 `true`。如果代理随后使用保存的文档发送完整的 `PUT`，就可能把 `maintenanceMode` 改回 `false`。请求确实按计划修改了副本数，却也撤销了代理从未看到的一项安全决定。

部分更新可以缩小影响范围，但并不能消除竞争。如果代理发送 PATCH 来替换 `/replicas`，这个字段仍可能在读取之后发生变化。更重要的是，把副本数设为 5 这个决定可能依赖于其他已经改变的字段。PATCH 描述的是请求正文的形状，并没有说明正文基于资源的哪个版本。

所以，“我们的界面只修改一个字段”并不是并发设计。界面有时能暂时掩盖问题，因为人行动缓慢，也能看到新页面。自主进程没有这些偶然的保护。

## ETag 标识客户端看到的表示

`ETag` 响应头是一种 HTTP 验证器。服务器返回资源表示时，可以附加一个标记，用来标识该表示的版本：

```http
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "deploy-8f31c2"

{
  "name": "billing-worker",
  "replicas": 3,
  "image": "registry.example/billing:2.4.0",
  "maintenanceMode": false
}
```

这个字符串没有规定的内部格式。它可以编码数据库修订号、内容哈希，或生成的不透明值。客户端必须把它当作不透明数据处理。不要解析标签来寻找修订号，也不要根据 JSON 正文自行构造标签。标签的含义由服务器决定。

RFC 9110 定义了实体标签，并区分强标签和弱标签。普通带引号的语法是强 ETag，例如 `"deploy-8f31c2"`。它表示按照服务器选定的表示语义，两个表示逐字节匹配。弱标签以 `W/` 开头，例如 `W/"deploy-8f31c2"`，只表示两个表示在语义上足够相似，可以用于缓存验证。

这个区别经常被忽略。弱验证器适合很多 GET 缓存检查，却不适合保护写入，因为两个“足够接近”的表示仍可能在某个字段上不同，而写入会破坏那个字段。RFC 9110 要求 `If-Match` 使用强比较。如果 API 只发布弱 ETag，就没有提供适合乐观并发控制的 ETag。

同一个资源的不同表示可能有不同 ETag。格式化 JSON、紧凑 JSON 或内容协商格式都可以分别拥有自己的验证器。这符合 HTTP 的行为，但会给 API 客户端带来麻烦。条件允许时，应为写入端点保留稳定的规范表示。这样客户端在 GET 中收到的 ETag，在 PUT、PATCH 和 DELETE 中仍然有意义。

## If-Match 将版本检查变成服务器必须履行的责任

`If-Match` 会把预期的 ETag 放到不安全请求中。只有当前表示与所提供的标签进行强匹配时，服务器才会执行该方法。

代理可以读取记录并保留收到的响应头：

```bash
curl -i \\
  -H 'Authorization: Bearer $TOKEN' \\
  https://api.example.test/v1/deployments/billing-worker
```

响应包含：

```http
ETag: "deploy-8f31c2"
```

随后，代理可以带上读取时得到的验证器，只发送自己真正打算做的最小变更：

```bash
curl -i -X PATCH \\
  -H 'Authorization: Bearer $TOKEN' \\
  -H 'Content-Type: application/json-patch+json' \\
  -H 'If-Match: "deploy-8f31c2"' \\
  --data '[{"op":"replace","path":"/replicas","value":5}]' \\
  https://api.example.test/v1/deployments/billing-worker
```

如果资源仍处于该版本，服务器就应用补丁并返回新的 ETag：

```http
HTTP/1.1 200 OK
ETag: "deploy-a19d77"
Content-Type: application/json

{
  "name": "billing-worker",
  "replicas": 5,
  "image": "registry.example/billing:2.4.0",
  "maintenanceMode": false
}
```

如果操作员的编辑先改变了当前版本，服务器会返回：

```http
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json

{
  "type": "https://api.example.test/problems/precondition-failed",
  "title": "The deployment changed after it was read",
  "status": 412,
  "detail": "Fetch the current deployment before retrying this update."
}
```

RFC 9110 规定，如果 `If-Match` 条件计算为假，源服务器不得执行请求的方法。这正是你通过它获得的保护。检查必须与修改处于同一个原子操作中。如果处理程序先读取数据行，在应用程序内存中比较修订号，然后稍后再写入，那么比较和写入之间仍然存在竞争窗口。

对于关系型数据库，实现通常类似于条件更新：

```sql
UPDATE deployments
SET replicas = :replicas,
    revision = revision + 1
WHERE id = :id
  AND revision = :expected_revision;
```

如果受影响的行数为零，API 返回 412。如果为一，API 返回更新后的文档，并根据新修订号生成下一个 ETag。把检查放进 `WHERE` 子句，或使用等效的事务性比较并设置原语。不要把检查拆成两个独立查询后，仍称其为安全。

## 版本字段在应用数据中表达同一契约

版本字段是应用层验证器。它向客户端公开一个修订号，客户端再把这个值放回请求正文、查询参数或专用请求头中。当客户端使用生成的 SDK、消息队列，或无法很好保留 HTTP 响应头的协议时，这种方式可能更容易处理。

GET 可能返回：

```json
{
  "id": "billing-worker",
  "revision": 42,
  "replicas": 3,
  "maintenanceMode": false
}
```

更新可以明确声明自己的预期：

```http
PATCH /v1/deployments/billing-worker HTTP/1.1
Content-Type: application/json

{
  "expectedRevision": 42,
  "replicas": 5
}
```

服务器会在原子操作中将 `expectedRevision` 与存储的修订号进行比较。成功后，它会递增修订号。版本不匹配时，服务器会使用文档规定的响应拒绝请求。如果该字段充当前置条件，通常返回 412。

不要把版本字段与时间戳混为一谈。单调递增的整数修订号能清楚地表达相等关系。时间戳会带来许多问题：服务器保存的精度是多少？两次写入会不会落在同一精度区间？序列化是否改变了数值？不同副本分配的时间是否一致？这些问题可以解决，但版本计数器需要解释的内容更少。

ETag 和版本字段并不互相排斥。API 可以同时公开两者，让 ETag 承载标准 HTTP 语义，让版本号帮助应用代码展示或协调变更。两者必须来自同一个已提交状态。如果一个说是版本 42，另一个却意外指向版本 41，客户端就没有可靠的恢复办法。

如果 ETag 和版本字段可能互相矛盾，不要允许客户端任选其一。为路由选择一个权威前置条件，或要求两者一致。灵活的输入契约看似友好，但当客户端发送过时的正文版本和新复制的请求头时，服务器就不知道应该相信哪项声明。

## If-None-Match 保护创建，不保护过时替换

`If-None-Match` 反转了判断条件。它表示，只有当前表示不匹配所提供标签中的任何一个时，方法才能继续。对于不安全方法，条件为假会产生 412。

它最有用的修改形式是 `If-None-Match: *`，意思是“只有当前不存在表示时才创建”。客户端可以安全地尝试创建一个命名资源：

```bash
curl -i -X PUT \\
  -H 'Authorization: Bearer $TOKEN' \\
  -H 'Content-Type: application/json' \\
  -H 'If-None-Match: *' \\
  --data '{"name":"nightly-export","schedule":"0 2 * * *"}' \\
  https://api.example.test/v1/jobs/nightly-export
```

如果另一个客户端已经创建了该任务，服务器会拒绝请求，而不是悄悄替换它。当代理推导出一个标识符，且不能接管同名的现有对象时，这种方式很有用。

不要在普通乐观并发控制中发送 `If-Match: *`。它只要求当前存在某个表示，等于允许代理覆盖任意当前版本，包括它从未读取过的版本。这是存在性保护，不是防止更新丢失的保护。

对于 GET 和 HEAD，`If-None-Match` 支持缓存。标签匹配时通常返回 `304 Not Modified`，不带响应正文。开发者最初接触 ETag，往往就是因为缓存行为。但不要因此把验证器当作只用于缓存的基础设施。同一个机制用于写入时，后果会严重得多。

## 过时写入的响应需要严格的代理策略

收到 412 后，应停止当前的修改计划。代理原先的前提已经失效，再次发送同一个请求并不能让它重新成立。

安全的恢复流程很简短：

1. 获取当前表示及其新的验证器。
2. 比较预期操作所依据的字段或状态假设，而不只是补丁中提到的字段。
3. 只有在无需重新解释且意图仍然正确时，才使用新验证器重试。
4. 如果当前状态改变了操作的含义、范围或风险，就请求批准或停止。

自动化客户端经常在第二步偷懒。假设代理读取成员列表后，计划把一名用户从访问组中移除。随后有人把该用户的角色从承包商改成了事故响应人员。重新获取数据后，代理仍然可以发出语法合法的移除请求。但它不应自动执行，因为角色变化让原计划变得可疑。

工作记录应保持简洁但完整：资源 URI、观察到的 ETag 或修订号、读取的字段、预期修改以及响应。工具运行器可以在短任务期间将这些内容保存在内存中。更长时间的自主工作流应将它们持久化到自己的可审计任务状态中。不要只让模型从文字中记住验证器；带引号的请求头值很容易被丢失、改动，或错误地用于另一个资源。

Sallyport 可以在代理执行 HTTP 调用时让代理无法接触 API 凭据，但代理仍然需要保存 ETag，并将它作为普通请求数据发送。凭据隔离和并发控制解决的是两种不同的失败，因此只要操作具有实际影响，就应同时使用二者。

## 412、409 和 428 表示不同的失败

当客户端提供了条件请求头或等效的文档化前置条件，而该条件为假时，应返回 `412 Precondition Failed`。这个响应准确说明了资源已经不再处于客户端声明的状态。

当服务器要求某条路由必须带前置条件，而客户端没有提供时，应返回 `428 Precondition Required`。RFC 6585 专门定义了这一状态，用来防止更新丢失。响应可以说明 PATCH 必须使用 `If-Match`，并在不会造成信息泄露的情况下附带当前 ETag。

即使版本条件通过，如果请求仍与应用状态冲突，也应返回 `409 Conflict`。例如，客户端发送的 `If-Match` 与当前发票匹配，但服务器仍因付款结算已经开始而拒绝取消。版本检查通过了，但业务命令仍与发票状态冲突。

不要把这些状态合并成一种通用错误。代理应采取不同反应：

- 收到 428 后，获取资源并带上所需条件重试。
- 收到 412 后，重新获取资源并重新评估原始意图。
- 收到 409 后，检查领域冲突，并按照 API 的业务解决流程处理。

有用的错误正文应指出资源，说明失败的条件但不要回显机密，并告诉客户端重新 GET 是否可能有帮助。它不应假装重试没有风险。HTTP 状态码提供机器可读的类别，正文则为操作人员提供决定下一步所需的上下文。

## Last-Modified 是兼容旧系统的备用方案

`Last-Modified` 和 `If-Unmodified-Since` 可以表达类似条件：只有资源自给定日期以来未发生变化时，才执行该方法。当旧 API 已经发布修改时间，而添加标签还需要时间时，它们仍然有用。

对于有实际后果的写入，它们更弱。HTTP 日期精度为一秒。同一秒内的两次修改可能产生相同的可见日期，客户端也可能不知道服务器存储的时间戳是否比请求头拥有更高精度。复制、时钟和序列化还会带来更多意外。

如果客户端同时发送 `If-Match` 和 `If-Unmodified-Since`，RFC 9110 规定优先使用 `If-Match`。这很合理。强验证器能进行精确的版本检查，而日期只是近似值。

除非标准请求头无法满足协议需求，否则不要自行创建 `X-If-Version` 请求头。自定义请求头很快会扩散到 SDK 和代理中，最后变成长期兼容性负担。`ETag` 和 `If-Match` 已经有明确语义、标准状态码，也受到普通 HTTP 工具的支持。

## PATCH 格式也需要单独测试

条件请求头保护的是资源版本，并不会验证补丁是否表达了安全转换。即使 ETag 正确，包含整个嵌套对象的 JSON Merge Patch 仍可能擦除同级字段。如果 API 表示的是成员已经改变的有序列表，JSON Patch 也可能指向错误的数组位置。

应选择与操作匹配的补丁格式。RFC 6902 定义的 JSON Patch 可以表达对特定路径执行的 `replace`、`add`、`remove` 和 `test` 等操作。它的 `test` 操作可以在执行后续操作前，断言文档中的某个值。RFC 7396 定义的 JSON Merge Patch 描述预期的部分文档，并将 `null` 视为删除。

文档级 ETag 应继续作为外层保护。如果某个操作依赖特定字段，值得明确表达，就再加入 JSON Patch 的 `test`：

```json
[
  {"op":"test","path":"/maintenanceMode","value":false},
  {"op":"replace","path":"/replicas","value":5}
]
```

如果另一个写入者在请求前修改了 `maintenanceMode`，请求就必须失败，而不是在维护期间增加容量。API 应说明 JSON Patch 测试失败时返回什么错误。许多实现使用 409，因为补丁指令与当前文档冲突，而外层 ETag 不匹配仍然是 412。如果客户端需要知道自己持有的是过时文档，还是发出了无效的状态相关请求，这种区分很有价值。

不要只依赖补丁中的 `test` 作为通用并发方案。它只能保护你记得测试的路径。强 ETag 保护的是代理实际用来制定计划的表示版本。

## 服务器必须在写入边界执行前置条件

只建议使用 `If-Match` 的 API 契约，在时间紧迫时一定会失效。一个客户端跳过它，另一个 SDK 忘记转发它，于是存在漏洞的端点就会成为代理通过示例发现的目标。当过时覆盖会造成明显损失时，应要求更新必须带前置条件。

处理程序应在执行副作用前拒绝缺少条件的请求。然后，它应把预期验证器传给真正修改状态的存储操作。如果资源由多张数据表或外部控制平面支持，应把比较和状态变更放在同一个事务中，或使用提供方的比较并设置操作。如果提供方做不到这一点，API 就不能诚实地承诺该写入能防止更新丢失。

应主动测试竞争场景。先将资源设置为修订 7，让客户端 A 和客户端 B 都执行 GET。让 A 带 `If-Match: "7"` 执行 PATCH，并确认它收到修订 8。然后让 B 带 `If-Match: "7"` 执行 PATCH，并确认它收到 412，同时确认 B 的预期变更没有出现。对 DELETE、完整 PUT，以及任何基于先前读取结果写入资源的批量操作，也重复相同测试。

还要测试危险的捷径：受保护路由缺少 `If-Match` 时必须收到 428，不能把 `If-Match: *` 当作过时写入保护，弱 ETag 也不能通过强比较。这些测试能发现新端点绕过常规仓储方法时产生的回归问题。

## 让安全路径比覆盖路径更容易

API 应在每次获取可变资源时返回 ETag，在每个不安全操作旁说明所需条件，并让 SDK 方法自然地携带验证器。为了避免破坏另一个写入者的工作，客户端不应该还要从某个隐蔽的响应对象中手动提取原始请求头。

对于代理，应将规划与执行分开。读取目标、记录验证器、说明预期修改，然后发出条件式调用。如果任何观察结果发生变化，就放弃计划中的写入，除非代理能够证明该变化无关紧要。这条规则听起来保守，因为它确实如此。另一种做法，是允许自动化进程依据自己明知已经过时的事实采取行动。

先从那些覆盖后会让人紧急处理的端点开始：部署设置、访问控制、客户记录、支付状态和机密元数据。加入 `If-Match`，让缺少前置条件的请求失败，并让两个写入者同时测试该路由。一旦服务器默认拒绝过时写入，代理的速度就不会再把普通并发变成悄无声息的损害。
