81 lines
3.7 KiB
Python
81 lines
3.7 KiB
Python
"""一次逻辑调用的**可选**墙钟硬边界(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
|