AgentDepthAgent 工程学习指南
输入概念或问题,检索正文和待编写目录。
第一次学习?从 L0 总览开始 →

工具超时以后:重试、幂等与失败恢复

我的学习 →

正在读取学习记录…

重试是在一次调用遇到暂时故障后,再发起同一业务请求。创建订单超时可能因为服务没收到请求,也可能因为订单已经创建、回执却没送回来。先判断调用能否安全重复,再决定是否重试,才能避免一次购买变成两张订单。

幂等契约让重复的同一业务意图只产生一次效果。调用方在各次尝试携带稳定业务键,服务端保存键、参数和结果;再次收到相同键同参数就返回原结果,同键不同参数则明确拒绝。设置 idempotent=True 是调用方确认已有这个契约,真正去重发生在服务端。

先把三个动作分开看:

概念一次订单调用中的含义
重试没拿到回复,再尝试同一请求
退避两次尝试之间隔一段时间,例如先等0.1秒,再等0.2秒
幂等每次都带同一个订单操作编号,服务查到已有订单就返回它

可以把操作编号看成取件码:同一张取件码反复出示,工作人员查到的是同一个包裹。买第二件商品时则产生新的操作编号,对应另一笔订单。

最容易观察的一条路线是:第一次请求创建订单→回复丢失→等待0.1秒→用原编号再请求→服务返回原订单。于是调用了2次,订单只有1张。下面先在输出中找到这条路线,再看另外三种情况。

运行四种订单调用结果

程序保留安全重试、未知写入、参数冲突和持续超时四个固定场景。模拟订单服务在完成写入后主动抛出超时,复现“业务已经发生,响应丢失”。attempts 是尝试次数,effects 是产生的订单效果次数,backoff_seconds 是重试前等待计划。

同一场景使用独立服务对象。invalid 先准备一笔旧订单,再用同一键提交不同商品;它的效果数1来自准备阶段。等待函数记录0.1、0.2秒的计划,不实际等待。

先识别错误与安全契约,再决定重试,尝试次数与业务效果分别计数;超时不证明未执行
图1:尝试次数与业务效果分别计数;超时不证明未执行。

保存完整程序并运行

先在 PowerShell 创建并进入学习目录:

Bash
mkdir "F:\学习\l4-retries" -Force
cd "F:\学习\l4-retries"

保存下面的文件后,在这个终端运行 dir 核对文件名,再执行本节给出的运行命令。

新建 F:\学习\l4-retries 文件夹,把下面完整代码保存为 retry_lab.py,然后在PowerShell运行:

Bash
cd "F:\学习\l4-retries"
ls
python -X utf8 retry_lab.py

先在 PowerShell 创建并进入学习目录:

Bash
mkdir "F:\学习\l4-retries" -Force
cd "F:\学习\l4-retries"

保存下面的文件后,在这个终端运行 dir 核对文件名,再执行本节给出的运行命令。

Python
"""模拟创建订单后回执丢失,用同一业务编号重试并检查订单数量。"""

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))

完整固定输出如下:

TXT
[
  {
    "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。它使用固定场景检查状态与业务结果,可以直接运行:

Bash
python -X utf8 -m unittest -v test_retry_lab.py
完整测试:test_retry_lab.py
Python
"""校验重复意图、冲突、未知结果和重试预算。"""

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()
课后练习展开习题收起习题

4 道练习 · 客观题规则评分 · 其余题目待批改

正在读取本地记录…

不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。

练习 1 / 4 · v1.0.0

选择题 · 超时意味着什么?

创建工单接口超时,但不支持幂等键,也没有确认业务结果。最稳妥的下一步是什么?

对应知识点:L4-retry

选择一个答案
练习 2 / 4 · v1.0.0

简答题 · 同一个键,换一组参数

同一业务键先创建 A 工单,重试时换成 B 工单。为什么不能静默返回 A 的结果?

对应知识点:L4-compensation

练习 3 / 4 · v1.0.0

代码题 · 证明响应丢失没有重复效果

基于下载的实验写测试:第一次写入后丢失响应,第二次使用相同键;分别断言尝试次数和业务效果次数。本站不执行代码。

对应知识点:L4-retry、L4-compensation

练习 4 / 4 · v1.0.0

场景题 · 三层重试叠加

调用层、工具层、SDK 各最多尝试三次。最坏底层调用可能达到多少次?你如何限制放大?

对应知识点:L4-retry

图片查看器
100%

文章配图 · 按 Esc 关闭