TT Lab
开始
学习 学习路径 课程

幂等性 — 点两次也只扣一次款

相同的键不一定代表相同的请求

在 TT Lab 中继续学习

目标

把键的范围、请求体规范化和键复用冲突分开,来判定一次请求是否为重新发送。

为什么重要

两个客户碰巧使用了同一个幂等键,结果一个客户收到了另一个客户的响应。在另一起请求中,同一个键对应的金额已经变了,系统却仍返回之前的成功结果。只比较幂等键本身,就会忽略请求的含义和安全边界。键要绑定到租户和操作范围,请求体的含义则要用单独的指纹来检查。

步骤

  1. 在 /root/work/idem-fingerprint-lab/service.py 中,valid_key(value) 只会原样返回由英文字母、数字、下划线、连字符组成的 1–64 个字符,其他输入一律抛出 ValueError。

先做一次准备。已有的文件不会被覆盖。

mkdir -p /root/work/idem-fingerprint-lab
test -e /root/work/idem-fingerprint-lab/service.py || cp /opt/fixtures/ten_labs/idem-fingerprint-lab/service.py /root/work/idem-fingerprint-lab/service.py
cd /root/work/idem-fingerprint-lab
  1. 在 /root/work/idem-fingerprint-lab/service.py 中,canonical(body) 只接受 dict,并返回以 sort_keys=True、separators=(',',':')、ensure_ascii=False、allow_nan=False 序列化得到的 JSON 字符串。无法序列化的值统一抛出 ValueError。

  2. 在 /root/work/idem-fingerprint-lab/service.py 中,fingerprint(body) 是对 canonical(body) 的 UTF-8 字节应用 SHA-256 后得到的 64 位 hex 字符串。

  3. 在 /root/work/idem-fingerprint-lab/service.py 中,scoped_key(tenant, method, path, key) 用 valid_key 校验 tenant 和 key,并把 method 转为大写。path 必须是以 / 开头的字符串。把这四个值编码成 separators=(',',':') 的 JSON 数组并返回。

  4. 在 /root/work/idem-fingerprint-lab/service.py 中,classify(record, digest) 在 record=None 时返回 'new',在 record['fingerprint']==digest 时返回 'replay',否则返回 'conflict'。

  5. 在 /root/work/idem-fingerprint-lab/service.py 中,remember(records, key, digest, response) 把 {fingerprint:digest, response:response 的 deepcopy} 保存到新键下。如果键已存在,就抛出 ValueError,并保留原有记录。

  6. 在 /root/work/idem-fingerprint-lab/service.py 中,replay(record) 返回 record['response'] 的深拷贝副本。

  7. 在 /root/work/idem-fingerprint-lab/service.py 中,execute(records, tenant, method, path, key, body, action) 会计算 scope 和指纹。结果为 new 时,把 action() 的结果 remember 下来并返回副本;结果为 replay 时,返回已有响应的副本;结果为 conflict 时,抛出 ValueError。action 抛出的异常要向外传播,并且不留下记录。

参考

校验键的语法

在 /root/work/idem-fingerprint-lab/service.py 中,valid_key(value) 只会原样返回由英文字母、数字、下划线、连字符组成的 1–64 个字符,其他输入一律抛出 ValueError。

先做一次准备。已有的文件不会被覆盖。

mkdir -p /root/work/idem-fingerprint-lab
test -e /root/work/idem-fingerprint-lab/service.py || cp /opt/fixtures/ten_labs/idem-fingerprint-lab/service.py /root/work/idem-fingerprint-lab/service.py
cd /root/work/idem-fingerprint-lab

限制键的长度和允许的字符,不把空键当作正常的重新发送来处理。

保存后用 bash /opt/lab/checks/idem-fingerprint-lab/01-contract.sh 确认。

对象顺序折叠,数组顺序保留

在 /root/work/idem-fingerprint-lab/service.py 中,canonical(body) 只接受 dict,并返回以 sort_keys=True、separators=(',',':')、ensure_ascii=False、allow_nan=False 序列化得到的 JSON 字符串。无法序列化的值统一抛出 ValueError。

如果对数组排序,就会改变用户所请求的操作顺序。

保存后用 bash /opt/lab/checks/idem-fingerprint-lab/02-contract.sh 确认。

计算请求体指纹

在 /root/work/idem-fingerprint-lab/service.py 中,fingerprint(body) 是对 canonical(body) 的 UTF-8 字节应用 SHA-256 后得到的 64 位 hex 字符串。

Python 的 hash() 每个进程都会变化,因此不能用作存储的指纹。

保存后用 bash /opt/lab/checks/idem-fingerprint-lab/03-contract.sh 确认。

按租户和操作区分键

在 /root/work/idem-fingerprint-lab/service.py 中,scoped_key(tenant, method, path, key) 用 valid_key 校验 tenant 和 key,并把 method 转为大写。path 必须是以 / 开头的字符串。把这四个值编码成 separators=(',',':') 的 JSON 数组并返回。

与简单地用分隔符拼接相比,对结构进行编码能让边界更清晰。路径的大小写要保留。

保存后用 bash /opt/lab/checks/idem-fingerprint-lab/04-contract.sh 确认。

区分三种判定

在 /root/work/idem-fingerprint-lab/service.py 中,classify(record, digest) 在 record=None 时返回 'new',在 record['fingerprint']==digest 时返回 'replay',否则返回 'conflict'。

不能仅仅因为键已经存在,就把所有重复请求都当作成功来重放。

保存后用 bash /opt/lab/checks/idem-fingerprint-lab/05-contract.sh 确认。

保存响应时做拷贝

在 /root/work/idem-fingerprint-lab/service.py 中,remember(records, key, digest, response) 把 {fingerprint:digest, response:response 的 deepcopy} 保存到新键下。如果键已存在,就抛出 ValueError,并保留原有记录。

响应里的列表如果也不拷贝,嵌套的状态就会被共享。

保存后用 bash /opt/lab/checks/idem-fingerprint-lab/06-contract.sh 确认。

读取响应时同样做拷贝

在 /root/work/idem-fingerprint-lab/service.py 中,replay(record) 返回 record['response'] 的深拷贝副本。

防止修改了第一次响应的调用方,连带改变后续重新发送的结果。

保存后用 bash /opt/lab/checks/idem-fingerprint-lab/07-contract.sh 确认。

业务函数只调用一次

在 /root/work/idem-fingerprint-lab/service.py 中,execute(records, tenant, method, path, key, body, action) 会计算 scope 和指纹。结果为 new 时,把 action() 的结果 remember 下来并返回副本;结果为 replay 时,返回已有响应的副本;结果为 conflict 时,抛出 ValueError。action 抛出的异常要向外传播,并且不留下记录。

只有连业务函数的调用次数和失败之后残留的记录都检查了,才能看清重新发送的契约。

保存后用 bash /opt/lab/checks/idem-fingerprint-lab/08-contract.sh 确认。