动手:用本地 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 | 清理后直接读取服务内部账本 | 给读者对照服务状态与宿主当时掌握的信息 |
图画的是 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| 模式 | 客户端在决策时看见 | 宿主结论 | 清理后的登记数 |
|---|---|---|---|
normal | POST 201,ID 与内容匹配 | confirmed_receipt | 1 |
timeout_then_lookup | 读超时;原 ID 的 GET 200 | confirmed_lookup | 1 |
same_id_retry | 读超时;同 ID、同内容再次 POST 得 200、replayed=true | confirmed_replay | 1 |
lookup_lag | 读超时;第一次 GET 404 | unknown_stop | 1;其实已登记,只是故意隐藏一次读回 |
late_commit | 读超时;GET 404 | unknown_stop | 1;原请求在宿主停止判断之后才获准登记 |
unsafe_new_id | 读超时;查询已确认,却仍用新 ID POST | duplicate_effect | 2 |
payload_conflict | 读超时;同 ID 换内容 POST 得 409 | conflict_stop | 1;原内容没有被替换 |
前三种确认路径退出码为 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,按下面五处阅读:
running_service()启动 loopback 服务,并在finally中先释放闸门,再shutdown()、server_close()和等待线程。清理是收尾,不是撤销业务;late_commit正是先让原请求完成收尾,再给观察者读账本。Handler.do_POST()收到并验证请求。late_commit在登记前等闸门;其余故障在Ledger.register()之后、响应之前等。故障点放在哪,决定超时后账本已经发生了什么。Ledger.register()在锁内核对 ID 与内容。重复返回原登记,冲突不写,不让两条并发请求分别绕过去重。request()使用HTTPConnection的超时与响应读取,遇到socket.timeout就记录传输超时。为稳定重现故障,初次请求在发送后、阻塞读响应前调用本地fixture_before_read:等待“已登记”或“已收到但暂不登记”的事件,再开始实际 socket 读超时。这是教学程序安排时序的回调,真实远端客户端无法读取这些内部事件。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 传输、服务,时间从上往下。先看“发送 → 登记 → 等待超时 → 查询 → 找回”这条主线。
- 发送登记请求。 客户端用
POST /operations发送 JSON,里面有操作编号operation_id和待登记内容payload。 - 服务保存记录。
Ledger.register()按这个编号登记原内容。 - 让回执晚一点返回。 演示程序让原 POST 等待
release事件。橙色等待条画的就是这段等待;与此同时,服务的另一线程仍能处理查询 GET。 - 客户端等到超时。
http.client的读取超时设置为 0.15 秒。等待响应时发生读取超时(read timeout),随后在finally里关闭原连接。这时登记已完成,关闭连接也没有撤销它。 - 用原编号查询。 宿主另开连接,访问
GET /operations/<原ID经URL编码>;代码中的quote(operation_id, safe="")负责把编号放进 URL。 - 取得记录。 这条路径返回 HTTP 200,JSON 包含
status="accepted"、原编号和原内容。accepted 是本程序约定的字段,表示已登记;接口实际没有返回 found 这个字面值。 - 核对后结束。
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。