解析失败、重试与回退:超时不等于失败
正在读取学习记录…
重试是程序在一次尝试没有得到可用结果后,按事先约定再尝试一次。回退是重试不合适或达到上限时的下一步,例如返回明确错误、查询已有结果或交给人工。上一站的输出校验给了我们“是否可用”的判断,这一篇再决定怎样恢复。
先接住前一站的解析错误:收到不可用的候选,有限次数重新取得回答;达到上限就交人工。再把场景换成内存订单服务,模拟“订单已创建、回执却丢了”。这时重放的已经是写操作,恢复方式必须依赖业务键和服务契约。
1. 从重新生成候选,到重新发送订单
先保存下面完整程序为 candidate_retry.py,运行 python -X utf8 candidate_retry.py。next 依次取准备好的回答,相当于替模型接口准备两张答卷:
# 按固定输入观察解析失败、重试与回退的处理过程和返回结果。
import json
responses = iter(['不是JSON', '{"city":"上海"}'])
for attempt in range(1, 3):
try:
value = json.loads(next(responses))
except ValueError:
print(f"attempt={attempt} parse_failed")
continue
if isinstance(value, dict) and set(value) == {"city"} and value["city"] == "上海":
print(f"attempt={attempt} accepted city={value['city']}")
break
print(f"attempt={attempt} rejected")
else:
print("needs_review")预期第1次 parse_failed,第2次 accepted city=上海。把第二个回答也改为 不是JSON,再跑会在两次失败后打印 needs_review。这里尚未调用天气或订单工具;换成真实模型接口时,每次重新生成会消耗请求时间与费用。
正常的订单创建
客户提交订单时,程序给这笔业务操作分配一个稳定的键,例如 checkout-1,并提交商品和数量。服务端创建 order-1,保存“键、参数、结果”的对应关系,再把 order-1 返回给客户。此时只调用一次,客户拿到了回执,流程就结束。下一次买另一件商品应使用新键;同一笔订单因网络问题重新发送时才沿用旧键。
这里的 OrderService 用内存模拟这段正常流程。先理解它保存了什么,再看回执丢失时,客户端为什么不能凭超时推断订单没有创建。
2. 把“失败”拆成不同证据
| 现象 | 已知事实 | 下一步 |
|---|---|---|
| JSON 无法解析 | 拿到的文本不符合接口 | 记录格式错误,有限重新生成,校验通过后再调用工具 |
| 参数结构不合法 | 字段或类型不满足契约 | 按字段规则重新取参数 |
| 读请求暂时超时 | 未拿到响应 | 按接口约定设置次数和等待上限 |
| 写请求超时 | 结果未知 | 用稳定幂等键查询或重试;无保证则人工确认 |
| 明确的权限拒绝 | 请求不允许 | 停止本次动作,显示需要的授权 |
对天气摘要,回退可以显示“当前无法确认”;对扣款或下单,未知结果要留下核查入口。就像付款后丢了小票,应查原订单是否已经付过款。退避(backoff)指每次重试前等一会儿;下面程序只把计划等待的0.1、0.2秒记录进列表,方便观察次数。
3. 决策顺序:先契约,后重试
幂等是“重复同一业务操作,最终效果与做一次相同”的接口性质。幂等键是调用方提供的稳定操作标识;同一次下单的所有尝试必须沿用同一键和相同参数。加一个键并不自动创造幂等性:服务端还需要保存结果,拒绝同键不同参数,并正确处理并发与持久化。AWS 的重试设计指南也把幂等作为重试的前提之一。
具体决策先问三件事:本次操作会写入吗?服务契约是否承诺相同 key 与相同参数只产生一次效果?重试预算还剩多少?若三项任一未知,先停止自动写入并查询状态或人工确认。对明确不可重试的结构、权限、业务错误,要让错误向上暴露。生产端还需总截止时间、单请求超时、抖动和全链路共享预算;多个软件层各自重试会放大调用量。
4. 在 PowerShell 复现“已提交但丢响应”
把 retry_lab.py、test_retry_lab.py 按下方代码逐个保存到同一个新文件夹。在资源管理器打开文件夹,地址栏输入 powershell,再运行:
macOS:先在 Python 篇的学习文件夹中打开终端,执行 source .venv/bin/activate。再输入 cd ,把当前例子的文件夹拖入终端并回车,运行下面的命令。
完整代码:retry_lab.py
"""模拟创建订单后回执丢失,用同一业务编号重试并检查订单数量。"""
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):
"""仅重试 TimeoutError;次数包含第一次调用,示例不加入随机抖动。
idempotent=True 表示调用者已按业务契约确认可安全重试,
这个标志本身不能让任意操作自动具备幂等性。
"""
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))完整代码:test_retry_lab.py
# 核对解析失败、重试与回退的正常结果和错误分支;断言失败时会显示对应的测试。
"""Offline tests: python -B -m unittest -v 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()ls
python retry_lab.py
python -m unittest -v test_retry_lab.py找不到 Python 时按 Python 环境篇检查安装;找不到测试模块时运行 Get-ChildItem 检查两个文件是否同目录。预期输出四行场景:retry-safe 尝试 2 次但副作用 1 次;unsafe 尝试 1 次后 manual_review;invalid 拒绝同键不同参数;exhausted 尝试 3 次后停止。测试文件覆盖成功、参数冲突、超时、预算非法等 12 项。
先读 OrderService.create_order:它把 (operation_key, payload) 与结果存入内存,第二次同键同参数直接返回旧结果;lose_response=True 在提交后抛 TimeoutError。再读 retry_operation:只捕获 TimeoutError,且只有调用方声明契约幂等才自动重试。max_attempts=3 包含第一次;最后一次失败后不再等待。无需修改函数默认值,直接运行下面已有的单次尝试测试;它传入 max_attempts=1,检查只调用一次、等待列表为空:
python -m unittest -v test_retry_lab.RetryLabTests.test_one_attempt_does_not_sleep_after_failure最后对比 unsafe:代码故意用非幂等操作模拟已经产生一次效果但响应丢失,程序返回 manual_review 而不是再次执行。这个值代表“需要核查”,绝不是业务成功。若你把 idempotent=True 强行用于未知写操作,调用方的一个布尔值不会给下游带来去重能力。
按时间顺序读一次“安全重试”
本地程序的 retry-safe 场景适合暂停下来逐步追踪。下面按时间顺序看内存订单服务里的记录:
| 时刻 | 调用方看到什么 | 服务端发生什么 | 下一步为什么安全 |
|---|---|---|---|
| 第 1 次请求前 | 准备键 checkout-1 与原始 payload | 尚无记录 | 键表示这一次业务操作 |
| 第 1 次请求后 | 收到 TimeoutError | 已写入 order-1,效果计数为 1 | 不能把超时解释成“没有下单” |
| 重试前 | 记录一次退避,沿用同一键同一参数 | 保存了键、参数和结果 | 服务契约明确同键同参返回原结果 |
| 第 2 次请求后 | 得到 order-1 | 效果计数仍为 1 | 返回的是首次效果,不是新订单 |
如果第 2 次把数量从 1 改为 2,下游拒绝同键不同参数;不能悄悄把它视为同一次订单。反过来,如果第 2 次换了一个新键,即使 payload 相同,服务也会把它看成新业务操作。稳定的业务操作 ID 与“每次 HTTP 请求一个新 ID”是不同概念。带写入动作的 Agent 应由确定的业务流程产生这个 ID,而不是让模型自由填写。
两层预算:次数和时间
先用图数一遍:max_attempts=3 包含第一次,所以最多只有两次重试和两段退避。再想最坏情况:如果每次应用尝试内部又由 SDK 发三次,下游可能收到九次请求。限制应用层次数并没有自动关闭 SDK 的重试。
次数上限之外,还要有从任务开始计算的总截止时间:单次请求可等30秒时,即使只试三次也会很慢。核对 SDK 和网关的重试配置,统一谁负责恢复,并记录实际尝试次数。随机抖动(jitter)是在计划等待时间上加入随机量,缓和大量客户端同时重试;它属于真实调用器配置,随文程序未实现。
亲手改坏一次,再恢复
在学习副本的 run_demo 中找到 operation() 最后的这一行:
return service.create_order("checkout-1", payload, lose_response=True)先只替换为下列两行,再运行主程序:
key = "checkout-2" if attempts == 2 else "checkout-1"
return service.create_order(key, payload, lose_response=True)retry-safe 应变成 attempts=3, status=succeeded, effects=2。第2次用了新键,创建第二笔订单且同样丢回执;第3次回到旧键,查回首笔订单。因此“最终成功”掩盖了重复效果。
随后把这两行替换成下面两行,保持原键,只在第2次改数量:
changed = {**payload, "quantity": 2} if attempts == 2 else payload
return service.create_order("checkout-1", changed, lose_response=True)retry-safe 应变成 attempts=2, status=rejected, effects=1:同键不同参数被拒绝。两轮都只改最后的调用,其他分支不动。恢复原行,再运行12项测试。
5. 回执丢失时还需要确认什么
真实服务的持久化、并发与取消
OrderService 是单进程、顺序执行的内存替身。进程重启会丢记录,并发写入没有锁;它不具备事务、持久化、跨实例去重或 exactly-once 保证。真正的 Agent 还要区分:模型输出解析错时不要触发工具;工具请求发出后超时时先查服务状态;用户取消时停止新尝试,但仍核查已经发出的写入。接入真实服务时,按供应商契约补持久化与故障恢复测试。
自查与下一站
超时只说明客户端没有及时收到响应。重试写操作前,先确认服务端怎样识别“同一笔业务”;同键同参可以查回原结果,换新键可能重复创建订单。解释为什么 retry-safe 可返回原结果,而 unsafe 要转人工核对。
上一站:约束生成。下一站:SSE、WebSocket 与流式响应。
现有练习
课后练习展开习题收起习题
正在读取本地记录…
不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。
选择题 · 先判断边界
关于“解析失败、重试与回退”,哪种做法更稳妥?
对应知识点:L1-retry
简答题 · 解释失败
写出一个失败输入并说明记录字段。
对应知识点:L1-retry
代码题 · 边界测试
补充正常、边界、失败三组输入和预期结果。
对应知识点:L1-retry
场景题 · 结果未知
证据不足时如何设计状态和人工接管?
对应知识点:L1-retry
资料
参考资料:AWS Builders' Library:超时、重试与抖动、AWS 重试退避模式、Python TimeoutError。
描述会发送给本站维护者,用于修订文章,保存90天。请只填写文章问题,省略个人信息、密钥和内部资料。