HTTP超时后的安全重试:幂等键与SQLite实验

客户端报超时,并不能说明服务端没有完成写入。本文让一个本机HTTP服务先提交SQLite事务,再延迟返回,实际观察普通POST重试和保留同一幂等键重试的差别,并验证内容冲突、并发与回滚。

适合正在为创建任务类接口增加重试逻辑的开发者。实验仅绑定127.0.0.1,使用临时数据库,不创建真实业务任务。需要Python 3.11+和curl;本次实际运行环境是macOS、Python 3.14.4。

下载安全重试实验包:完整HTTP服务、对照脚本、结果和15项测试(ZIP,约7KB)

先看到同一个错误背后的两种结果

解压后进入retry-lab-v1目录,运行下面命令。脚本自动选择本机端口、创建临时SQLite数据库,并在结束时关闭服务和清理文件,不需要手动填写端口或启动另一个终端。

python3 run_lab.py
python3 -m unittest -v

服务端第一次请求先完成COMMIT,再延迟响应350毫秒;客户端总时限为50毫秒。两个对照中,curl都真实返回28。程序在第二次请求前还确认了服务端已提交,避免仅凭时间猜测写入是否发生。

对照路径 超时后任务数 重试后任务数 重试返回ID
普通POST 1 2 2
同一幂等键 1 1 1

最值得关注的是第二列:客户端尚未拿到响应,任务已经存在。换成长一点的超时可能降低这种情况的频率,却没有建立重试去重规则;恢复网络后再次发起写入,仍可能创建第二条记录。

键要标识同一件事,而不是每次连接

示例的/jobs要求Idempotency-Key;/unsafe-jobs是故意不去重的对照。服务端识别的是一项业务动作的标识,网络重试必须保留同一键。若每次超时都重新生成键,数据库看到的仍然是不同任务。

Idempotency-Key和Idempotency-Replayed在这里是明确实现的应用协议,并不是向任意POST添加请求头就会自动生效。HTTP层身份固定为fixed-demo-client,没有登录功能;存储层用身份与键组成唯一范围。真实身份必须来自服务端认证,并为不同业务操作划定命名空间。

CREATE TABLE replay (
    client TEXT,
    key TEXT,
    payload_hash TEXT,
    response TEXT,
    PRIMARY KEY (client, key)
);

一项操作如果可能由多个客户端发起,也要先决定谁拥有这个标识、怎样防止跨身份错误复用。不能让客户端随意填另一个人的身份来查询旧结果。实验只处理“创建虚构任务”这一种操作。

同键不同内容必须冲突,不能返回一份看似成功的旧答案

请求体只允许task字段,去掉两端空白后长度为1到80字符;未知字段和重复JSON字段会被拒绝。经过明确规范化的内容再计算SHA-256,作为相同键下核对语义内容的依据。

请求情况 本例HTTP结果 任务变化
首次合法请求 201;重放标记false 创建1条
同键、同内容 201;重放标记true;原响应体不变 不新增
同键、不同内容 409 不新增
缺少键或重复JSON字段 400 不新增

这张表也说明,客户端不能只看201就断言“刚刚又创建了一条”。重放路径返回保存的原结果,必须结合协议、任务ID和标记理解。HTTP层的这些分支已在本地测试,不只是调用存储函数模拟。

规范化是业务决定。本例把task两端空白视为无意义,所以“ demo ”与“demo”可复用结果。真实字段若把空白当作内容,就不能照搬。哈希只用于检测请求内容冲突,不是用户身份凭证。

把任务行和重放记录放进同一个事务

危险的写法是先提交任务,再单独保存幂等记录:两步之间一旦出错,重试时就找不到原任务的对应关系。本例在同一个SQLite事务内完成查询旧键、核对摘要、创建任务和保存响应,最后一起提交。

BEGIN IMMEDIATE;
-- 已有键:核对payload_hash并读取原response。
-- 新键:插入jobs,再插入replay。
COMMIT;

真实实现还处理异常回滚。测试在插入任务之后、写入重放记录之前故意抛错,确认任务数仍为0;随后同键请求能够重新创建。仅给重放表增加唯一约束不够,还得确保业务写入没有先独立提交。

BEGIN IMMEDIATE提前取得写事务,多个相同键的请求会等待或遇到忙状态。测试中的8个并发调用最终只有1条任务且返回同一响应;这是少量回归检查,不是吞吐或压力测试。生产实现还要设计忙等待、总时限、失败反馈及数据库连接容量。

测试必须越过函数边界,也要说明没有越过的边界

实验的请求路径经过真实本机HTTP和curl,而不是把超时结果预先写进变量。测试包括重放响应及响应头、同键异内容409、重复字段400、身份范围、规范化、重新打开数据库、并发、插入失败回滚、普通POST重复、非法输入和两条真实超时路径,共15项。

{
  "mode": "same-key",
  "first_curl_exit": 28,
  "jobs_after_timeout": 1,
  "retry_response": {"job_id": 1, "task": "demo-report"},
  "jobs_after_retry": 1
}

上面来自本次run_lab.py输出。重新打开数据库的测试验证记录被持久化,并不等于对所有进程崩溃时点做了恢复演练。受控延迟也没有覆盖DNS错误、代理重试、断电或响应流中途损坏;不能把一组本机通过的结果扩大为所有故障都已解决。

数据库只有一行,不代表邮件或外部动作只执行一次

这里创建的是任务记录,没有工作进程去发送邮件、扣款或调用外部系统。外部执行成功、确认消息丢失之后的重复消费,需要下游自己的幂等标识或事务性发件箱等设计。一个本地事务不会自动覆盖两个独立系统。

示例没有删除幂等记录的策略。真实服务需要明确记录保留期限、键过期后的处理,以及客户端重试窗口;如果服务端比客户端更早忘记这个键,重复创建的风险会回来。结果缓存、错误响应和长期运行中的存储增长也要有各自约定。

把“结果未知”作为排错流程中的正式状态

遇到超时,先保留身份、业务操作标识、发送时间及脱敏后的参数摘要。若接口支持按原标识查询任务,先查询;若协议明确支持幂等重放,就保留同一键和相同语义内容,并限制总尝试次数。没有这些能力时,应记录“结果未知”,而不是自动按失败再执行。

读者可以先把自己的请求形状替换进这个离线实验,再验证三个问题:同一操作是否稳定复用标识,任务与重放记录能否一起回滚,以及冲突请求是否被清楚拒绝。接入生产前,还需要独立完成认证、业务授权和外部副作用的验证。

代码包SHA-256:

caea02e293e7de3f2549a93d76c4a9a693908f28120bfa386beb646cf9a4c37d

资料与前置阅读

资料查阅与编辑日期:2026年10月20日。作者:玖柒。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享