工具超时以后:重试、幂等与失败恢复
正在读取学习记录…
重试是在一次调用遇到暂时故障后,再发起同一业务请求。创建订单超时可能因为服务没收到请求,也可能因为订单已经创建、回执却没送回来。先判断调用能否安全重复,再决定是否重试,才能避免一次购买变成两张订单。
幂等契约让重复的同一业务意图只产生一次效果。调用方在各次尝试携带稳定业务键,服务端保存键、参数和结果;再次收到相同键同参数就返回原结果,同键不同参数则明确拒绝。设置 idempotent=True 是调用方确认已有这个契约,真正去重发生在服务端。
先把三个动作分开看:
| 概念 | 一次订单调用中的含义 |
|---|---|
| 重试 | 没拿到回复,再尝试同一请求 |
| 退避 | 两次尝试之间隔一段时间,例如先等0.1秒,再等0.2秒 |
| 幂等 | 每次都带同一个订单操作编号,服务查到已有订单就返回它 |
可以把操作编号看成取件码:同一张取件码反复出示,工作人员查到的是同一个包裹。买第二件商品时则产生新的操作编号,对应另一笔订单。
最容易观察的一条路线是:第一次请求创建订单→回复丢失→等待0.1秒→用原编号再请求→服务返回原订单。于是调用了2次,订单只有1张。下面先在输出中找到这条路线,再看另外三种情况。
运行四种订单调用结果
程序保留安全重试、未知写入、参数冲突和持续超时四个固定场景。模拟订单服务在完成写入后主动抛出超时,复现“业务已经发生,响应丢失”。attempts 是尝试次数,effects 是产生的订单效果次数,backoff_seconds 是重试前等待计划。
同一场景使用独立服务对象。invalid 先准备一笔旧订单,再用同一键提交不同商品;它的效果数1来自准备阶段。等待函数记录0.1、0.2秒的计划,不实际等待。
保存完整程序并运行
先在 PowerShell 创建并进入学习目录:
mkdir "F:\学习\l4-retries" -Force
cd "F:\学习\l4-retries"保存下面的文件后,在这个终端运行 dir 核对文件名,再执行本节给出的运行命令。
新建 F:\学习\l4-retries 文件夹,把下面完整代码保存为 retry_lab.py,然后在PowerShell运行:
cd "F:\学习\l4-retries"
ls
python -X utf8 retry_lab.py先在 PowerShell 创建并进入学习目录:
mkdir "F:\学习\l4-retries" -Force
cd "F:\学习\l4-retries"保存下面的文件后,在这个终端运行 dir 核对文件名,再执行本节给出的运行命令。
"""模拟创建订单后回执丢失,用同一业务编号重试并检查订单数量。"""
from copy import deepcopy
import json
import time
class OrderService:
"""按业务编号保存订单参数和结果,重复收到同一请求时返回已有结果。"""
def __init__(self):
self._records = {}
self.effects = 0
def create_order(self, operation_key, payload, *, lose_response=False):
if operation_key in self._records:
saved_payload, result = self._records[operation_key]
if payload != saved_payload:
raise ValueError("operation key reused with different payload")
return deepcopy(result)
self.effects += 1
result = {"order_id": f"order-{self.effects}"}
self._records[operation_key] = (deepcopy(payload), result)
# 订单已提交,随后丢失响应;超时不能证明写入没有发生。
if lose_response:
raise TimeoutError("order committed, response lost")
return deepcopy(result)
def retry_operation(operation, *, idempotent, max_attempts=3, sleep=time.sleep):
"""仅重试超时,次数包含首次调用。
idempotent 表示调用方已确认服务具备幂等契约;设置布尔值不会改变服务行为。
"""
if type(max_attempts) is not int or max_attempts < 1:
raise ValueError("max_attempts must be a positive integer")
for attempt in range(1, max_attempts + 1):
try:
return operation()
except TimeoutError:
if not idempotent:
return {"status": "manual_review"}
if attempt == max_attempts:
raise
sleep(0.1 * 2 ** (attempt - 1))
def run_demo():
"""四个固定场景,注入等待函数记录退避计划而不实际等待。"""
results = []
for scenario in ("retry-safe", "unsafe", "invalid", "exhausted"):
service = OrderService()
attempts = 0
unsafe_effects = 0
delays = []
payload = {"item": "book", "quantity": 1}
if scenario == "invalid":
service.create_order("checkout-1", payload)
def operation():
nonlocal attempts, unsafe_effects
attempts += 1
if scenario == "unsafe":
unsafe_effects += 1
raise TimeoutError("non-idempotent order committed, response lost")
if scenario == "exhausted":
raise TimeoutError("service unavailable before commit")
if scenario == "invalid":
return service.create_order("checkout-1", {"item": "pen", "quantity": 1})
return service.create_order("checkout-1", payload, lose_response=True)
try:
result = retry_operation(operation, idempotent=scenario != "unsafe",
sleep=delays.append)
status = result.get("status", "succeeded")
except ValueError:
status = "rejected"
except TimeoutError:
status = "exhausted"
results.append({"scenario": scenario, "status": status, "attempts": attempts,
"effects": service.effects + unsafe_effects,
"backoff_seconds": delays})
return results
if __name__ == "__main__":
print(json.dumps(run_demo(), indent=2))完整固定输出如下:
[
{
"scenario": "retry-safe",
"status": "succeeded",
"attempts": 2,
"effects": 1,
"backoff_seconds": [
0.1
]
},
{
"scenario": "unsafe",
"status": "manual_review",
"attempts": 1,
"effects": 1,
"backoff_seconds": []
},
{
"scenario": "invalid",
"status": "rejected",
"attempts": 1,
"effects": 1,
"backoff_seconds": []
},
{
"scenario": "exhausted",
"status": "exhausted",
"attempts": 3,
"effects": 0,
"backoff_seconds": [
0.1,
0.2
]
}
]四条结果怎样决定后续路线
retry-safe 尝试2次、效果1次。第一次保存订单后丢失响应,第二次凭同一键读回原结果。unsafe 只尝试1次就转 manual_review(人工确认),因为调用方没有安全重复的服务契约。invalid 一次尝试后拒绝,修正参数才有意义。exhausted 最多尝试3次后停止,且没有写入效果。
未知结果不能直接等同于失败。实际执行器可以按业务标识查询订单;查到原订单就记录回执,确认不存在后再按服务契约决定能否重试。这里的 unsafe_effects 是测试用模拟器知道的事实,调用方仍没有回执,所以转人工而非报告成功。
错误分类先于退避算法
retry_operation 只捕获 TimeoutError;ValueError 和程序异常直接向上返回。权限错误、参数错误通常不能靠等待修复。使用真实SDK时需要核对它的异常类型与内置重试,不能直接把所有异常统一重跑。
请求层3次、工具层3次、SDK层3次可能变成27次底层调用。重试责任应集中,或让各层共享总预算。服务端限流提示可能要求等待指定时间,执行器仍要判断任务剩余时间是否够用。可参考限制重试建议。
次数、单次超时与总时限
max_attempts=3 包含第一次调用,因此失败后最多再试两次。第3次失败直接抛出,不再等待。0.1、0.2秒来自指数退避;生产并发调用通常要加入抖动,避免所有客户端同时重试。固定程序没有随机数,方便核对输出。
次数上限无法中止一次永不返回的网络请求。客户端还需要单次超时,工作流需要总时限,退避等待和下一次调用都不能超出总预算。取消信号也要传到真正等待网络结果的那层,而非只在外层停止打印。
重复意图怎样绑定到参数
OrderService 保存参数与结果的深拷贝,防止调用方后来修改字典,影响已登记订单。同一键不同参数会抛冲突异常;用户确实想再创建一张相同订单,应使用新的业务键。
内存字典只在单进程生命周期内有效。生产服务需通过持久唯一键和原子写入处理并发、崩溃与保留期。幂等记录过期后,旧请求再次到达的行为也必须有契约。关于这些服务端设计可以阅读幂等 API 设计。
重试后还需要什么恢复机制
成功回执保存进任务状态;未知结果走查单或人工;已成功的前置步骤遇到后续失败,进入补偿流程。连续故障还可以进入降级与熔断。本函数到次数上限停止当前调用,跨调用的失败统计要由另一份状态保存。
保留的12项测试覆盖输入和结果拷贝、同键去重、参数冲突、未知结果、非超时异常、一次与三次上限,以及非法预算。它们对业务效果数与尝试数分别作断言,安全重试允许“尝试多次、效果一次”。
改一处输入,核对机制变化
把 retry_operation 的默认 max_attempts=3 改为 2,运行 python -X utf8 retry_lab.py。exhausted的attempts变为2、等待计划只剩0.1;retry-safe仍为2次尝试、1张订单。恢复3再运行,核对两次退避计划。
运行配套测试
将下面测试保存为同一文件夹的 test_retry_lab.py。它使用固定场景检查状态与业务结果,可以直接运行:
python -X utf8 -m unittest -v test_retry_lab.py完整测试:test_retry_lab.py
"""校验重复意图、冲突、未知结果和重试预算。"""
import importlib.util
import unittest
from pathlib import Path
class RetryLabTests(unittest.TestCase):
def setUp(self):
path = Path(__file__).with_name("retry_lab.py")
self.assertTrue(path.is_file(), "retry_lab.py must implement the lab")
spec = importlib.util.spec_from_file_location("retry_lab", path)
self.lab = importlib.util.module_from_spec(spec)
spec.loader.exec_module(self.lab)
def test_same_key_same_payload_returns_original_order(self):
service = self.lab.OrderService()
first = service.create_order("op-1", {"item": "book", "quantity": 1})
second = service.create_order("op-1", {"quantity": 1, "item": "book"})
self.assertEqual(first, second)
self.assertEqual(service.effects, 1)
def test_conflicting_payload_is_rejected_without_effect(self):
service = self.lab.OrderService()
service.create_order("op-1", {"item": "book"})
with self.assertRaises(ValueError):
service.create_order("op-1", {"item": "pen"})
self.assertEqual(service.effects, 1)
def test_different_keys_create_different_orders(self):
service = self.lab.OrderService()
self.assertNotEqual(service.create_order("a", {"item": "book"}),
service.create_order("b", {"item": "book"}))
self.assertEqual(service.effects, 2)
def test_input_and_output_mutation_cannot_change_saved_record(self):
service = self.lab.OrderService()
payload = {"items": ["book"]}
result = service.create_order("a", payload)
payload["items"].append("pen")
result["order_id"] = "tampered"
self.assertEqual(service.create_order("a", {"items": ["book"]}),
{"order_id": "order-1"})
with self.assertRaises(ValueError):
service.create_order("a", payload)
def test_lost_response_after_commit_is_safe_to_retry(self):
# 回执丢失后的第二次尝试必须复用业务键,效果仍只能一次。
service = self.lab.OrderService()
attempts = []
delays = []
def operation():
attempts.append(1)
return service.create_order("a", {"item": "book"}, lose_response=True)
result = self.lab.retry_operation(operation, idempotent=True, sleep=delays.append)
self.assertEqual(result, {"order_id": "order-1"})
self.assertEqual(len(attempts), 2)
self.assertEqual(service.effects, 1)
self.assertEqual(delays, [0.1])
def test_unsafe_unknown_outcome_requires_manual_review(self):
calls, delays = [], []
def operation():
calls.append(1)
raise TimeoutError("response lost; outcome unknown")
self.assertEqual(self.lab.retry_operation(operation, idempotent=False,
sleep=delays.append),
{"status": "manual_review"})
self.assertEqual(len(calls), 1)
self.assertEqual(delays, [])
def test_non_timeout_error_is_not_retried(self):
for error_type in (ValueError, RuntimeError):
with self.subTest(error_type=error_type):
calls, delays = [], []
def operation():
calls.append(1)
raise error_type("rejected")
with self.assertRaises(error_type):
self.lab.retry_operation(operation, idempotent=True, sleep=delays.append)
self.assertEqual(len(calls), 1)
self.assertEqual(delays, [])
def test_exhaustion_has_three_attempts_and_two_backoffs(self):
calls, delays = [], []
def operation():
calls.append(1)
raise TimeoutError("still unavailable")
with self.assertRaises(TimeoutError):
self.lab.retry_operation(operation, idempotent=True, sleep=delays.append)
self.assertEqual(len(calls), 3)
self.assertEqual(delays, [0.1, 0.2])
def test_one_attempt_does_not_sleep_after_failure(self):
delays = []
def operation():
raise TimeoutError("unavailable")
with self.assertRaises(TimeoutError):
self.lab.retry_operation(operation, idempotent=True, max_attempts=1,
sleep=delays.append)
self.assertEqual(delays, [])
def test_success_returns_immediately(self):
delays = []
self.assertEqual(self.lab.retry_operation(lambda: "ok", idempotent=False,
sleep=delays.append), "ok")
self.assertEqual(delays, [])
def test_invalid_attempt_budget_is_rejected_before_call(self):
for budget in (0, -1, 1.5, "3", True, None):
with self.subTest(budget=budget):
calls = []
with self.assertRaises(ValueError):
self.lab.retry_operation(lambda: calls.append(1), idempotent=True,
max_attempts=budget, sleep=lambda _: None)
self.assertEqual(calls, [])
def test_demo_reports_four_scenarios(self):
results = self.lab.run_demo()
self.assertEqual([row["scenario"] for row in results],
["retry-safe", "unsafe", "invalid", "exhausted"])
self.assertEqual([row["attempts"] for row in results], [2, 1, 1, 3])
self.assertEqual(results[0]["effects"], 1)
self.assertEqual(results[1]["status"], "manual_review")
self.assertEqual(results[2]["status"], "rejected")
self.assertEqual(results[3]["status"], "exhausted")
if __name__ == "__main__":
unittest.main()课后练习展开习题收起习题
正在读取本地记录…
不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。
选择题 · 超时意味着什么?
创建工单接口超时,但不支持幂等键,也没有确认业务结果。最稳妥的下一步是什么?
对应知识点:L4-retry
简答题 · 同一个键,换一组参数
同一业务键先创建 A 工单,重试时换成 B 工单。为什么不能静默返回 A 的结果?
对应知识点:L4-compensation
代码题 · 证明响应丢失没有重复效果
基于下载的实验写测试:第一次写入后丢失响应,第二次使用相同键;分别断言尝试次数和业务效果次数。本站不执行代码。
对应知识点:L4-retry、L4-compensation
场景题 · 三层重试叠加
调用层、工具层、SDK 各最多尝试三次。最坏底层调用可能达到多少次?你如何限制放大?
对应知识点:L4-retry
描述会发送给本站维护者,用于修订文章,保存90天。请只填写文章问题,省略个人信息、密钥和内部资料。