"""一次逻辑调用的**可选**墙钟硬边界(issue #22;1.3.6 设计 §3 方案 A)。 只依赖标准库与 `errors.py`(依赖铁律最内层),供三个公开边界各包一次: 期限治理的是**等待**,不是"到期即无副作用"——在途请求可能已发出、已被上游 计费,清理照旧在 `finally` 完成,故返回时刻 = 期限 + 清理耗时。 缺省 `None` 时**完全不进上下文管理器**,行为逐字等于 1.3.5。 """ from __future__ import annotations import asyncio import math from typing import TYPE_CHECKING from polygateway.errors import CallDeadlineExceeded if TYPE_CHECKING: from collections.abc import Awaitable def ensure_call_deadline(value: object, origin: str) -> float | None: """全装配路径共用的期限值域校验: `None` 或**有限正数秒**,否则当场 `ValueError`。 装配错误不属降级面(缺失/非法配置直接报错,不静默取默认值)。`bool` 必须先判: `isinstance(True, int)` 为真,放行会让 `call_deadline_s=True` 变成"1 秒期限" 这种没人写得出来的意图。巨大 int(如 `10**400`)超出 float 值域,`float()` 会抛 `OverflowError`——它不是 `ValueError` 的子类,泄漏出去会绕过调用方的 `except ValueError`,故在此统一成同一种装配错误。 `origin` 写进消息,用于在多 scope 部署里定位到底是哪个键/哪个参数非法。 """ if value is None: return None if isinstance(value, bool) or not isinstance(value, (int, float)): raise ValueError(f"{origin} 必须是 None 或有限正数秒: {value!r}") try: seconds = float(value) except OverflowError: raise ValueError(f"{origin} 必须是有限正数秒: {value!r}") from None if not math.isfinite(seconds) or seconds <= 0: raise ValueError(f"{origin} 必须是有限正数秒: {value!r}") return seconds async def with_call_deadline[T](aw: Awaitable[T], *, deadline_s: float | None, scope: str) -> T: """给一个 awaitable 加一层可选期限;到期抛 `CallDeadlineExceeded`。 三条实现红线: 1. **校验先于构造 awaitable**——调用方必须先 `ensure_call_deadline`,否则非法值 抛错时会遗留未 await 的协程(`RuntimeWarning` + 资源不释放)。 2. 只用**相对时长**,绝不把注入的 `now` 换算成绝对截止时刻: 注入钟跳变 10^6 秒不该凭空触发期限。 3. 判据必须是**局部变量身份比较**,不可退化成只看 `cm.expired()`: 到期后清理路径自抛的 `TimeoutError` 也发生在 `expired()` 为真时,只看它 会把别人的超时改标成本层期限;`__cause__` 启发式同样失效(内层 `asyncio.timeout` 抛出的 `TimeoutError` 其 `__cause__` 也是 `CancelledError`)。 不新增后台任务、不 `shield`、不改异常对象:外部取消照常以 `CancelledError` 穿透。 """ if deadline_s is None: # 未启用: 不进上下文管理器,逐字走 1.3.5 旧路径 return await aw # 体内(含清理路径)自抛的 TimeoutError 的**身份**,唯一可靠的区分依据 inner_timeout: BaseException | None = None # 先建对象再进上下文: `as cm` 只在 `__aenter__` 返回后才绑定, 而 except 块无条件 # 读 `cm`——进入阶段一旦抛 TimeoutError 就会变成 NameError 掩盖真实错误 cm = asyncio.timeout(deadline_s) try: async with cm: try: return await aw except TimeoutError as exc: inner_timeout = exc raise except TimeoutError as exc: if cm.expired() and exc is not inner_timeout: raise CallDeadlineExceeded(scope=scope, deadline_s=deadline_s) from None raise