跳到正文

动手:用本地 HTTP 观察回执与查询 ​

返回学习路线 · 先读内存版回执练习 · 权限与恢复对照

这个进阶小实验把上一课的内存调用换成本机 HTTP。客户端向服务登记 publish-v1,收到回执后确认;回执迟迟不来时,再开一条连接按原操作 ID 查询。你会先看见一次正常登记,再观察超时后怎样找回结果。

服务只把版本标签登记在内存账本里。TCP/HTTP 请求、并发和读超时是真实发生的,业务和故障时机由教学程序安排。初读本书可以跳过代码,只沿图看发送、登记和查询三条泳道;想运行时,先读内存版练习中的操作 ID 与对账说明。

运行前请确认 Python 3.10+,并允许本机 loopback(只连本机的网络接口)监听。本实验只用标准库,无账号、API Key 或真实发布操作。

先看一次正常登记 ​

从仓库或解压后的阅读包根目录运行:

程序只在本机 127.0.0.1 临时启动服务,结束后自动关闭。无需账号,也不会写磁盘或发布内容。请保留本机地址,不要改成对外监听的 0.0.0.0;http.server 不适合生产服务。清理服务的实现放在后面的可选代码导读中。

bash
python3 examples/http-receipt/demo.py --mode normal
python3 -m unittest discover -s examples/http-receipt -p 'test_*.py'

normal 应收到 POST 201,ID 和内容匹配,host.status=confirmed_receipt。这说明宿主拿到了登记回执;清理后的账本有 1 条记录。省略 --mode 也会运行这个正常路径。

接着让原请求的回执超时:

bash
python3 examples/http-receipt/demo.py --mode timeout_then_lookup

第一次 submit 返回 transport=timeout、http_status=null。宿主用原 ID 查询,收到 GET 200,核对 ID 与 payload=publish-v1 后返回 confirmed_lookup;事后账本仍为 1 条。字段分工如下:

输出位置谁取得的信息能说明什么
host.events客户端收到的 HTTP 回执或超时决定继续、确认或保持未知的依据
host.status宿主根据这些结果作出的判断本实验的登记结果
observer_after_cleanup清理后直接读取服务内部账本给读者对照服务状态与宿主当时掌握的信息

服务先登记,客户端超时后用另一条连接按原 ID 对账

查看原尺寸 SVG(可缩放)

不看图的说明

图画的是 timeout_then_lookup:POST 携带 ID 和内容,服务登记后等待;客户端读超时并关闭原连接,再用新连接 GET 查询原 ID。确认来自查询回执,主路径没有第二次写入。迟到的原响应可能在连接关闭后才尝试发送。

再比较七种情况 ​

先选 normal、已跑过的 timeout_then_lookup、same_id_retry,回答每次会登记几条;再看 lookup_lag 和 late_commit 的两种 404。最后两条是反例和冲突,不是推荐恢复策略。

bash
python3 examples/http-receipt/demo.py --mode normal
python3 examples/http-receipt/demo.py --mode same_id_retry
python3 examples/http-receipt/demo.py --mode lookup_lag
python3 examples/http-receipt/demo.py --mode late_commit
python3 examples/http-receipt/demo.py --mode unsafe_new_id
python3 examples/http-receipt/demo.py --mode payload_conflict
模式客户端在决策时看见宿主结论清理后的登记数
normalPOST 201,ID 与内容匹配confirmed_receipt1
timeout_then_lookup读超时;原 ID 的 GET 200confirmed_lookup1
same_id_retry读超时;同 ID、同内容再次 POST 得 200、replayed=trueconfirmed_replay1
lookup_lag读超时;第一次 GET 404unknown_stop1;其实已登记,只是故意隐藏一次读回
late_commit读超时;GET 404unknown_stop1;原请求在宿主停止判断之后才获准登记
unsafe_new_id读超时;查询已确认,却仍用新 ID POSTduplicate_effect2
payload_conflict读超时;同 ID 换内容 POST 得 409conflict_stop1;原内容没有被替换

前三种确认路径退出码为 0;后四种为 2,表示未确认、冲突或刻意的错误恢复。状态细节在 JSON 中,账本登记数则在事后观察字段里。测试命令应通过 16 个测试。同 ID 重放若再次超时,继续保持未知;收到匹配原 ID 的 409 回执后,才标为 conflict_stop。

两种 404 看上去相同,发生时机不同。lookup_lag 已经登记,第一次查询暂时隐藏它;late_commit 查询时尚未登记,旧请求仍在处理,清理释放闸门后才登记。宿主当时都返回 unknown_stop。事后观察让读者看见差别,也说明查询应结合旧请求是否还在处理来理解。

同 ID 重放由服务实现 ​

操作 ID 是本服务 JSON 请求中的应用字段,不是标准 HTTP 自带的幂等承诺。这里只保证在同一个服务进程、同一账本内:同 ID 和同 payload 重放返回原登记;同 ID 不同 payload 返回 409;核对与新增在同一把锁下完成,避免两个线程同时查无记录后各写一次。

所以 same_id_retry 返回原登记,unsafe_new_id 会新增一条。关键在服务端:它识别原 ID,并在锁内完成检查与新增;日志或提示词中的 ID 只是标记。HTTP 的幂等与重试说明可帮助理解协议,本例的 POST 重放规则则由应用明确实现。

接真实 API 前,先核对同键内容规则、键保留时间、结果查询、取消能力和重试策略,再检查实际业务成果。下面的代码只实现同进程内存账本规则。

沿五个位置读代码(可选) ​

打开 demo.py,按下面五处阅读:

  1. running_service() 启动 loopback 服务,并在 finally 中先释放闸门,再 shutdown()、server_close() 和等待线程。清理是收尾,不是撤销业务;late_commit 正是先让原请求完成收尾,再给观察者读账本。
  2. Handler.do_POST() 收到并验证请求。late_commit 在登记前等闸门;其余故障在 Ledger.register() 之后、响应之前等。故障点放在哪,决定超时后账本已经发生了什么。
  3. Ledger.register() 在锁内核对 ID 与内容。重复返回原登记,冲突不写,不让两条并发请求分别绕过去重。
  4. request() 使用 HTTPConnection 的超时与响应读取,遇到 socket.timeout 就记录传输超时。为稳定重现故障,初次请求在发送后、阻塞读响应前调用本地 fixture_before_read:等待“已登记”或“已收到但暂不登记”的事件,再开始实际 socket 读超时。这是教学程序安排时序的回调,真实远端客户端无法读取这些内部事件。
  5. run() 按 HTTP 回执决定 host.status,确认时核对状态码、ID 和内容。内部同步负责安排故障,未达到预期时序就抛异常;账本只在独立的事后观察中读取。慢登记测试延迟 0.25 秒,检查登记早于读超时。

选一个变化,写出预测 ​

任选一个变化,先写你预计的状态和登记数,再用代码或测试对照。想改文件时,在临时练习副本操作:

  • 第二次 POST 保持原 ID,但把内容改成 publish-v2。
  • 多个并发 POST 使用同 ID、同内容。
  • 第一次查询收到 404 后,用新 ID 再发一次。

对照输出,写清楚发出的请求、收到的结果、服务的重放规则、下一步处理。例如查询返回 404 时,还需要知道旧请求是否仍在处理,才能决定接下来怎么做。

参考结果 ​

同 ID 换内容时,payload_conflict 返回 409、conflict_stop,原 publish-v1 保留。并发去重测试 test_atomic_parallel_deduplication 发四个真实请求,断言一个 201、三个 200 和一条登记。404 后换 ID 会绕过去重:原记录可能已有,旧请求也可能随后登记。

本实验的范围 ​

账本只在同一个服务进程内保存,进程退出就丢失;这里没有键有效期、租户隔离、业务事务、跨机器协调或灾难恢复。因此它演示的是一次本地 HTTP 与应用去重过程,并非分布式“恰好一次”、真实发布、互联网丢包或 OS 沙箱测试。

下一步可以使用专用临时持久存储,测试重启、冲突和读回;那是新的实验。本课只确认内存登记,不检查真实版本质量,也没有真实模型参与。

图的文字说明 · http-receipt-timeline ​

服务已经登记了一条记录,客户端却没等到回执。这张图跟着本书的本地 HTTP 演示程序,展示怎样用原编号把记录找回来;详细操作见练习正文。

三列从左到右是客户端、HTTP 传输、服务,时间从上往下。先看“发送 → 登记 → 等待超时 → 查询 → 找回”这条主线。

  1. 发送登记请求。 客户端用 POST /operations 发送 JSON,里面有操作编号 operation_id 和待登记内容 payload。
  2. 服务保存记录。 Ledger.register() 按这个编号登记原内容。
  3. 让回执晚一点返回。 演示程序让原 POST 等待 release 事件。橙色等待条画的就是这段等待;与此同时,服务的另一线程仍能处理查询 GET。
  4. 客户端等到超时。 http.client 的读取超时设置为 0.15 秒。等待响应时发生读取超时(read timeout),随后在 finally 里关闭原连接。这时登记已完成,关闭连接也没有撤销它。
  5. 用原编号查询。 宿主另开连接,访问 GET /operations/<原ID经URL编码>;代码中的 quote(operation_id, safe="") 负责把编号放进 URL。
  6. 取得记录。 这条路径返回 HTTP 200,JSON 包含 status="accepted"、原编号和原内容。accepted 是本程序约定的字段,表示已登记;接口实际没有返回 found 这个字面值。
  7. 核对后结束。 accepted() 检查 HTTP 状态、JSON 状态、编号和内容,都匹配时,run() 才写入 host.status="confirmed_lookup"。找到的是原记录,无需再发一次登记请求。

另一条路径可能返回 404 not_found:记录暂时查不到。它可能尚未登记,也可能已经保存但查询还看不到,还可能有旧请求正在路上。因此,这时保留原编号,进入 unknown_stop,也就是暂停并说明结果未知。登记前等待、延迟可见等情况,在程序和练习中分别演示。

本图确认的是这条记录是否已登记。真实业务还需按自己的要求检查,例如记录登记之后是否完成后续处理。

为了稳定演示超时,request() 在发送后、调用 getresponse() 前,先运行测试用的 fixture_before_read。主路径等待服务发出 received 和 committed 事件,保证已经收到并登记,再开始等待回执;因此即使登记本身慢于 0.15 秒,仍能得到图中的先后次序。late_commit 模式只等 received,随后再展示登记延后的情况。

这些事件只用于本地演示安排时间。宿主判断记录是否存在,仍依据查询返回的 HTTP 状态和内容。真实客户端通常拿不到服务内部事件,要按实际接口检查。

最后清理时,程序释放 release,服务才尝试返回旧 POST 的回执。原连接已经关闭,是否送达无法确认,所以图上只画了等待,没有画保证送达的迟到回执。


在线预览稿:书稿仍在校稿,系统篇以文内固定源码版本为准;静态阅读不等于运行验收。发现错误或有改进建议?按本章填写邮件,或查看反馈说明。

书稿提交:33f7dcdf7ac3。