fix(network): recover shared pools after repeated pool timeouts

This commit is contained in:
jxxghp
2026-10-18 09:01:33 +08:00
parent dd371ec4cc
commit 30378f2fc8
8 changed files with 601 additions and 30 deletions

View File

@@ -25,6 +25,8 @@ from requests.adapters import HTTPAdapter
from urllib3.exceptions import InsecureRequestWarning
from app.runtime.correlation import with_correlation_header
from app.runtime.log import logger
from app.runtime.tasks import get_task_registry
urllib3.disable_warnings(InsecureRequestWarning)
@@ -143,21 +145,25 @@ class _NonClosingTransportProxy:
包装共享传输对象,转发请求但不转发局部关闭调用。
每次请求创建的 AsyncClient 退出时保留底层连接池;连接池最终由
aclose_shared_async_transports 统一关闭。
aclose_shared_async_transports 统一关闭;连续取池超时的旧池在借用结束后释放。
"""
__slots__ = ("_wrapped",)
__slots__ = ("_wrapped", "_recovery")
def __init__(self, wrapped: httpx2.AsyncBaseTransport):
def __init__(self, wrapped: httpx2.AsyncBaseTransport) -> None:
"""
保存由进程统一管理生命周期的底层传输对象。
"""
self._wrapped = wrapped
with _shared_async_transports_lock:
self._recovery = _shared_transport_recovery.get(wrapped)
async def __aenter__(self) -> "_NonClosingTransportProxy": # pragma: no cover - 简单转发
async def __aenter__(self) -> "_NonClosingTransportProxy":
"""
进入局部上下文时继续复用共享传输对象。
"""
if self._recovery is not None:
self._recovery.users += 1
return self
async def __aexit__(
@@ -169,8 +175,10 @@ class _NonClosingTransportProxy:
"""
退出局部上下文时保留由进程统一管理的连接池。
"""
# 故意 no-op:不向底层 transport 传播 __aexit__,避免连接池被清空
return None
if self._recovery is not None:
self._recovery.users -= 1
if self._recovery.retired and self._recovery.users == 0:
_schedule_retired_transport_close(self._wrapped)
async def aclose(self) -> None:
"""
@@ -183,7 +191,19 @@ class _NonClosingTransportProxy:
"""
将异步请求转发给共享底层传输。
"""
return await self._wrapped.handle_async_request(request)
try:
response = await self._wrapped.handle_async_request(request)
except httpx2.PoolTimeout:
if self._recovery is not None:
_record_shared_pool_timeout(self._wrapped, self._recovery)
raise
except httpx2.RequestError:
if self._recovery is not None:
self._recovery.failures = 0
raise
if self._recovery is not None:
self._recovery.failures = 0
return response
_SharedTransportKey = Tuple[
@@ -207,6 +227,31 @@ _SharedTransportKey = Tuple[
_shared_async_transports: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, collections.OrderedDict[_SharedTransportKey, httpx2.AsyncHTTPTransport]] = weakref.WeakKeyDictionary()
# 不同线程各自驱动的事件循环并发首次写入外层弱字典时,需要互斥保护
_shared_async_transports_lock = threading.Lock()
# 连续三次取池超时才更换池,普通连接/读取超时不触发恢复。
_SHARED_POOL_TIMEOUT_THRESHOLD = 3
class _SharedTransportRecovery:
"""
记录单个共享池的失败与借用状态,退役后等局部客户端退出再关闭。
"""
def __init__(self, key: _SharedTransportKey) -> None:
"""
初始化池状态,以配置键约束最多两代在途连接池。
:param key: 当前池配置,用于识别同配置下仍未关闭的退役池
"""
self.key = key
self.users = 0
self.failures = 0
self.retired = False
self.closing = False
self.closed = False
_shared_transport_recovery: weakref.WeakKeyDictionary[httpx2.AsyncBaseTransport, _SharedTransportRecovery] = weakref.WeakKeyDictionary()
_retired_async_transports: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, set[httpx2.AsyncBaseTransport]] = weakref.WeakKeyDictionary()
# 每个事件循环允许的最大共享 transport 桶数;超出后按 LRU 淘汰最久未用桶。
_MAX_SHARED_TRANSPORTS_PER_LOOP = 32
# 默认的最大 keep-alive 连接数
@@ -283,7 +328,107 @@ def _discard_pending_eviction_task(task: asyncio.Task[Any]) -> None:
with _shared_async_transports_lock:
_pending_eviction_tasks.discard(task)
if not task.cancelled():
task.exception()
error = task.exception()
if error is not None:
logger.warning(f"共享 HTTP 连接池关闭失败:{type(error).__name__}")
def _create_shared_async_transport(
key: _SharedTransportKey,
) -> httpx2.AsyncHTTPTransport:
"""
按已有配置创建新池,不复用故障池的内部连接或请求队列。
"""
proxy, verify, http2, trust_env, max_keepalive, max_connections, expiry = key
transport = httpx2.AsyncHTTPTransport(
http2=http2, proxy=proxy, verify=verify, trust_env=trust_env,
limits=httpx2.Limits(
max_keepalive_connections=max_keepalive,
max_connections=max_connections,
keepalive_expiry=expiry,
),
)
with _shared_async_transports_lock:
_shared_transport_recovery[transport] = _SharedTransportRecovery(key)
return transport
async def _close_retired_async_transport(transport: httpx2.AsyncBaseTransport) -> None:
"""
关闭退役池并释放恢复状态;关闭异常仍由托管任务收集。
"""
try:
await transport.aclose()
except BaseException:
with _shared_async_transports_lock:
_shared_transport_recovery[transport].closing = False
raise
else:
with _shared_async_transports_lock:
recovery = _shared_transport_recovery.get(transport)
if recovery is not None:
recovery.closed = True
retired = _retired_async_transports.get(asyncio.get_running_loop())
if retired is not None:
retired.discard(transport)
def _schedule_retired_transport_close(transport: httpx2.AsyncBaseTransport) -> None:
"""
将退役池关闭任务交给已有淘汰任务集合持有,避免取消调用方中断释放。
"""
with _shared_async_transports_lock:
recovery = _shared_transport_recovery[transport]
if recovery.closing or recovery.closed:
return
recovery.closing = True
try:
task = get_task_registry().create(
_close_retired_async_transport(transport),
owner="http.transport.retire",
cancel_on_shutdown=False,
)
except RuntimeError:
recovery.closing = False
logger.warning("后台任务登记器已停止,退役 HTTP 连接池保留至宿主关闭时回收")
return
_pending_eviction_tasks.add(task)
task.add_done_callback(_discard_pending_eviction_task)
def _record_shared_pool_timeout(
transport: httpx2.AsyncBaseTransport, recovery: _SharedTransportRecovery,
) -> None:
"""
连续取池失败时只替换仍为当前代的池;旧代请求退出前不重复轮换。
不重放当前请求,因此非幂等请求仍只发送一次。
"""
recovery.failures += 1
if recovery.retired or recovery.failures < _SHARED_POOL_TIMEOUT_THRESHOLD:
return
loop = asyncio.get_running_loop()
with _shared_async_transports_lock:
per_loop = _shared_async_transports.get(loop)
if any(
_shared_transport_recovery[old].key == recovery.key
for old in _retired_async_transports.get(loop, ())
):
return
if per_loop is None:
return
for key, current in per_loop.items():
if current is transport:
replacement = _create_shared_async_transport(key)
per_loop[key] = replacement
recovery.retired = True
with _shared_async_transports_lock:
_retired_async_transports.setdefault(loop, set()).add(transport)
logger.warning(
f"共享 HTTP 连接池连续 {recovery.failures} 次 PoolTimeout,"
f"后续请求切换新池,旧池等待 {recovery.users} 个在途客户端退出后关闭"
)
return
def _get_shared_async_transport(
@@ -335,17 +480,7 @@ def _get_shared_async_transport(
return transport
# 首次见到这个配置,创建新的共享 transport 桶
transport = httpx2.AsyncHTTPTransport(
http2=http2,
proxy=proxy,
verify=verify,
trust_env=trust_env,
limits=httpx2.Limits(
max_keepalive_connections=max_keepalive_connections,
max_connections=max_connections,
keepalive_expiry=keepalive_expiry,
),
)
transport = _create_shared_async_transport(key)
per_loop[key] = transport
# LRU 淘汰:超出上限时关闭并移除最久未用桶
@@ -376,15 +511,22 @@ async def aclose_shared_async_transports() -> None:
# 弹出而非 get+clear,避免外层 dict 残留空 OrderedDict 占位
with _shared_async_transports_lock:
per_loop = _shared_async_transports.pop(loop, None)
retired = _retired_async_transports.get(loop, set())
pending_evictions = [
task
for task in _pending_eviction_tasks
if task.get_loop() is loop
]
retiring = [
transport for transport in retired
if not _shared_transport_recovery[transport].closing
]
for transport in retiring:
_shared_transport_recovery[transport].closing = True
transports = list(per_loop.values()) if per_loop else []
if per_loop:
per_loop.clear()
if not transports and not pending_evictions:
if not transports and not pending_evictions and not retiring:
return
# 并行关闭:每个 transport 的 TLS close_notify 各占一个 RTT,
# 顺序等待会线性放大 shutdown 耗时;return_exceptions 让单点失败
@@ -392,6 +534,7 @@ async def aclose_shared_async_transports() -> None:
await asyncio.gather(
*pending_evictions,
*(t.aclose() for t in transports),
*(_close_retired_async_transport(t) for t in retiring),
return_exceptions=True,
)
with _shared_async_transports_lock:

View File

@@ -763,7 +763,7 @@ flowchart LR
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 1123 |
| 内部导入边 | 9,584 |
| 内部导入边 | 9,586 |
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
| Direct egress | 49(债务已清零,49 条精确 containment) |
@@ -778,7 +778,7 @@ Outbox 按 topic 有界并行恢复复用 `ThreadHelper`,生命周期关闭通
外部 MCP schema 紧凑投影由纯函数模块 `app.agent.tools.projection` 拥有,仅在请求头显式 opt-in 后应用;新增 1 个模块和 1 条合法端点依赖边,未新增 SCC。`list_exposed_tools` 返回类型注解使端点类型债务减少 5 项。
目录监控成功通知在批次完成后复用主事件循环开启静默窗口,新增 2 条合法 Chain 到 Runtime 依赖边(含父包展开),未新增模块或 SCC。成功通知聚合复用既有 owner 和领域模型,统一交由通知模板渲染;移除汇总发送端的模板上下文直连后,原聚合实现的 14 条新增依赖边减为 12 条,未新增模块或 SCC。社区勋章名单读取复用 `app.foundation.url` 标准化 GitHub 镜像地址,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,584;站点 API 依赖显式导入 SiteQueryPort 与 SiteStagingPort,新增 1 条合法类型边;订阅独立日志复用运行时上下文、系统日志端口与资源鉴权,新增 19 条合法依赖边(含父包展开),未新增模块或 SCC;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条 Schema 到 Domain 的合法依赖边(含父包展开);种子下载限流窗口由 `app.domain.torrent` 定义,失败冷却 owner 复用同一语义,新增 1 条合法 Chain 到 Domain 的导入边;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条模块导入边;资源过滤动作 `app.workflow.actions.filter_torrents` 与动作包共用的领域还原函数同样把 Schema 资源还原为领域对象后再交给过滤链,新增 6 条模块导入边。音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 587、`app.factory` 为 600、`app.main` 为 602。专辑订阅入库对账新增 `app.chain.subscribe.music` 一个职责 owner;按注入的下载/整理历史 Port 读取事实,复用音频标签/CUE 读取和领域覆盖规则,依赖图增加 18 条合法边,不引入跨层数据库或 Adapter 直连。订阅业务活动新增 `app.application.subscription.activity` 及对应 DB Model、Oper、Adapter 四个模块,三入口与功能前提交对照均只增加这四个模块;性能基线仅同步结构计数,保留原有历史耗时采样和预算。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。
目录监控成功通知在批次完成后复用主事件循环开启静默窗口,新增 2 条合法 Chain 到 Runtime 依赖边(含父包展开),未新增模块或 SCC。成功通知聚合复用既有 owner 和领域模型,统一交由通知模板渲染;移除汇总发送端的模板上下文直连后,原聚合实现的 14 条新增依赖边减为 12 条,未新增模块或 SCC。社区勋章名单读取复用 `app.foundation.url` 标准化 GitHub 镜像地址,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,586;站点 API 依赖显式导入 SiteQueryPort 与 SiteStagingPort,新增 1 条合法类型边;订阅独立日志复用运行时上下文、系统日志端口与资源鉴权,新增 19 条合法依赖边(含父包展开),未新增模块或 SCC;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条 Schema 到 Domain 的合法依赖边(含父包展开);种子下载限流窗口由 `app.domain.torrent` 定义,失败冷却 owner 复用同一语义,新增 1 条合法 Chain 到 Domain 的导入边;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条模块导入边;资源过滤动作 `app.workflow.actions.filter_torrents` 与动作包共用的领域还原函数同样把 Schema 资源还原为领域对象后再交给过滤链,新增 6 条模块导入边。音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 587、`app.factory` 为 600、`app.main` 为 602。专辑订阅入库对账新增 `app.chain.subscribe.music` 一个职责 owner;按注入的下载/整理历史 Port 读取事实,复用音频标签/CUE 读取和领域覆盖规则,依赖图增加 18 条合法边,不引入跨层数据库或 Adapter 直连。订阅业务活动新增 `app.application.subscription.activity` 及对应 DB Model、Oper、Adapter 四个模块,三入口与功能前提交对照均只增加这四个模块;性能基线仅同步结构计数,保留原有历史耗时采样和预算。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。
架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event
语义门禁,`Check host architecture snapshot` 再执行快照测试及一次

View File

@@ -100,7 +100,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
| 指标 | 当前值 | 解释 |
|---|---:|---|
| 宿主 Python 模块 / 内部依赖边 | 1123 / 9,584 | Outbox 恢复的日志与关闭屏障新增 5 条合法依赖边(含父包展开),未新增模块或 SCC。 `dependency-baseline.json` 当前快照;豆瓣短评与影评新增 `app.schemas.comment` 一个模块和 3 条合法依赖边(含父包展开),模块方法契约同步登记同步与异步入口,未新增 SCC 或跨层直连。 `dependency-baseline.json` 当前快照;站点 API 依赖显式导入 `SiteQueryPort` 与 `SiteStagingPort`,新增 1 条合法类型边;集数定位直接导入 FileItem owner,替换 1 条间接导出依赖边,模块数、边数、SCC 与边界不变;监控分发器直接导入 FileItem owner,以 1 条 Schema 依赖边替换间接导出依赖,模块数、边数、SCC 与边界不变;工作流扫描、整理与下载动作直接复用已有 Schema owner,新增 3 条合法依赖边,模块数、SCC 与边界不变;工作流媒体过滤与下载查询复用已有 Schema 元素类型,新增 2 条合法依赖边,模块数、SCC 与边界不变;TheAudioDB 相关艺术家结果复用既有领域类型,新增 2 条合法 Chain 到 Domain 类型依赖边(含父包展开),模块数、SCC 与边界不变;群晖 Chat 渠道类型改为直接导入已有 Schema owner,移除 1 条间接导出依赖边,模块数、SCC 与边界不变;系统链、转移规划、订阅查询和规则服务改为直接导入已有 Schema owner,新增 2 条 owner 依赖边并移除 4 条间接导出依赖边,模块数、SCC 与层间边界不变;Telegram Rich Message 失败降级复用 `app.schemas.exception` 的 `ImmediateException`,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。目录监控成功通知在批次完成后复用主事件循环开启静默窗口,新增 2 条合法 Chain 到 Runtime 依赖边(含父包展开),未新增模块或 SCC。成功通知聚合复用既有 owner 和领域模型,统一交由通知模板渲染;移除汇总发送端的模板上下文直连后,原聚合实现的 14 条新增依赖边减为 12 条,未新增模块或 SCC。社区勋章名单读取复用 `app.foundation.url` 标准化 GitHub 镜像地址,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。专辑订阅入库对账新增 1 个 Chain owner 和 18 条合法依赖边(含父包展开),仍经注入 Port 读取历史并通过既有 mutation 写入;订阅业务活动新增 4 个模块和 50 条合法内部依赖边(含父包展开),Application 仅定义事实与持久化 Port,DB Adapter/Oper 实现存取,组合根注入,未增加 Application/Chain 到具体 Adapter 或数据库的直连;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条合法依赖边(含父包展开);种子失败冷却复用 `app.domain.torrent` 的限流语义,新增 1 条合法导入边;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条合法模块导入边,资源过滤动作复用同一还原再新增 6 条;LLM 独占连接关闭复用宿主线程池;歌词字形转换复用 `app.foundation.text`;订阅创建使用 `app.domain.context.MediaInfo` 和运行时配置端口,将非 TMDB 影视身份投影为 TMDB 身份;本次安全与可靠性修复新增 4 条合法依赖边:WebAgent 复用渠道类型、宿主历史清理复用文件系统代理、两个下载器复用纯领域种子哈希规则,未新增模块或 SCC;IP 归属地适配器复用 `app.schemas.common.JsonData` 声明原始 JSON 返回,新增 2 条合法类型依赖边(含父包展开),未新增模块、循环或越层依赖;TMDb 认证与片单读取复用模块既有异常,新增 2 条同模块依赖边;Session 类型使扫描器识别原有两处 close,发送与会话关闭路径未变;Audiences 构造参数复用既有 RequestUtils 类型,新增 3 条合法依赖边(含父包展开);插件实例历史脏行清理复用既有 `app.schemas.plugin.validate_plugin_id` 进行 fail-closed 身份校验,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC |
| 宿主 Python 模块 / 内部依赖边 | 1123 / 9,586 | HTTP 共享池自恢复复用运行时日志与 TaskRegistry,新增 2 条合法 Adapter 到 Runtime 依赖边,未新增模块、SCC 或外连入口。 Outbox 恢复的日志与关闭屏障新增 5 条合法依赖边(含父包展开),未新增模块或 SCC。 `dependency-baseline.json` 当前快照;豆瓣短评与影评新增 `app.schemas.comment` 一个模块和 3 条合法依赖边(含父包展开),模块方法契约同步登记同步与异步入口,未新增 SCC 或跨层直连。 `dependency-baseline.json` 当前快照;站点 API 依赖显式导入 `SiteQueryPort` 与 `SiteStagingPort`,新增 1 条合法类型边;集数定位直接导入 FileItem owner,替换 1 条间接导出依赖边,模块数、边数、SCC 与边界不变;监控分发器直接导入 FileItem owner,以 1 条 Schema 依赖边替换间接导出依赖,模块数、边数、SCC 与边界不变;工作流扫描、整理与下载动作直接复用已有 Schema owner,新增 3 条合法依赖边,模块数、SCC 与边界不变;工作流媒体过滤与下载查询复用已有 Schema 元素类型,新增 2 条合法依赖边,模块数、SCC 与边界不变;TheAudioDB 相关艺术家结果复用既有领域类型,新增 2 条合法 Chain 到 Domain 类型依赖边(含父包展开),模块数、SCC 与边界不变;群晖 Chat 渠道类型改为直接导入已有 Schema owner,移除 1 条间接导出依赖边,模块数、SCC 与边界不变;系统链、转移规划、订阅查询和规则服务改为直接导入已有 Schema owner,新增 2 条 owner 依赖边并移除 4 条间接导出依赖边,模块数、SCC 与层间边界不变;Telegram Rich Message 失败降级复用 `app.schemas.exception` 的 `ImmediateException`,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。目录监控成功通知在批次完成后复用主事件循环开启静默窗口,新增 2 条合法 Chain 到 Runtime 依赖边(含父包展开),未新增模块或 SCC。成功通知聚合复用既有 owner 和领域模型,统一交由通知模板渲染;移除汇总发送端的模板上下文直连后,原聚合实现的 14 条新增依赖边减为 12 条,未新增模块或 SCC。社区勋章名单读取复用 `app.foundation.url` 标准化 GitHub 镜像地址,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。专辑订阅入库对账新增 1 个 Chain owner 和 18 条合法依赖边(含父包展开),仍经注入 Port 读取历史并通过既有 mutation 写入;订阅业务活动新增 4 个模块和 50 条合法内部依赖边(含父包展开),Application 仅定义事实与持久化 Port,DB Adapter/Oper 实现存取,组合根注入,未增加 Application/Chain 到具体 Adapter 或数据库的直连;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条合法依赖边(含父包展开);种子失败冷却复用 `app.domain.torrent` 的限流语义,新增 1 条合法导入边;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条合法模块导入边,资源过滤动作复用同一还原再新增 6 条;LLM 独占连接关闭复用宿主线程池;歌词字形转换复用 `app.foundation.text`;订阅创建使用 `app.domain.context.MediaInfo` 和运行时配置端口,将非 TMDB 影视身份投影为 TMDB 身份;本次安全与可靠性修复新增 4 条合法依赖边:WebAgent 复用渠道类型、宿主历史清理复用文件系统代理、两个下载器复用纯领域种子哈希规则,未新增模块或 SCC;IP 归属地适配器复用 `app.schemas.common.JsonData` 声明原始 JSON 返回,新增 2 条合法类型依赖边(含父包展开),未新增模块、循环或越层依赖;TMDb 认证与片单读取复用模块既有异常,新增 2 条同模块依赖边;Session 类型使扫描器识别原有两处 close,发送与会话关闭路径未变;Audiences 构造参数复用既有 RequestUtils 类型,新增 3 条合法依赖边(含父包展开);插件实例历史脏行清理复用既有 `app.schemas.plugin.validate_plugin_id` 进行 fail-closed 身份校验,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC |
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |

View File

@@ -23,6 +23,15 @@ are forbidden.
- User-Agent headers
- Retry logic
The shared asynchronous transport tracks consecutive `PoolTimeout` failures per
event loop and configuration. After three failures it replaces that pool for
subsequent requests and logs the recovery. Responses and other request errors
reset the count. It does not replay the failed request. The retired pool closes
after its existing client contexts exit, including streaming reads and
cancellation. While a retired pool remains unclosed, the same configuration
cannot rotate again, bounding the active generations to two. Caller-owned
clients retain their own transport lifecycle.
```python
# 此处展示适配器实现;应用服务和业务链通过注入的接口访问网络能力
from app.adapters.network.http import RequestUtils

View File

@@ -211,7 +211,7 @@
"bindings": [
"import:httpx2 as httpx2"
],
"fingerprint": "73879afc31531cc27241f21e0edf3b8e493a7d9aef973d0c2e28165d006e631d",
"fingerprint": "cf347be42a531743cdae292eecd31f714be16ad13584a88f49a9102688262682",
"kind": "raw_transport",
"source": "app.adapters.network.http",
"target": "httpx2",
@@ -225,8 +225,8 @@
"AsyncRequestUtils.get_stream._open_stream|call:AsyncClient",
"AsyncRequestUtils.get_stream._open_stream|call:Timeout",
"AsyncRequestUtils.get_stream._open_stream|call:stream",
"_get_shared_async_transport|call:AsyncHTTPTransport",
"_get_shared_async_transport|call:Limits",
"_create_shared_async_transport|call:AsyncHTTPTransport",
"_create_shared_async_transport|call:Limits",
"_h2_proxy_breaker_key|call:URL"
]
},
@@ -974,8 +974,8 @@
"runtime_only": true
}
},
"edge_count": 9584,
"edge_sha256": "fc5c745bb4d0a28cd0c7e5536d74e92cd122acee1dda876ce8cc1a46771a2f23",
"edge_count": 9586,
"edge_sha256": "da7db077e5f3e0a34e90b0a5d695e9ee2221bdf23417473a6384fcddb0da5c67",
"edges": [
"app -> app.foundation",
"app -> app.foundation.environment",
@@ -1096,6 +1096,8 @@
"app.adapters.network.feishu -> app.runtime.log",
"app.adapters.network.http -> app.runtime",
"app.adapters.network.http -> app.runtime.correlation",
"app.adapters.network.http -> app.runtime.log",
"app.adapters.network.http -> app.runtime.tasks",
"app.adapters.network.webpush -> app.adapters",
"app.adapters.network.webpush -> app.adapters.network",
"app.adapters.network.webpush -> app.adapters.network.http",

View File

@@ -105,7 +105,7 @@
{"source": "app.adapters.network.doh", "target": "socket.getaddrinfo", "kind": "protocol_operation", "fingerprint": "4ff03419dfacc6bf582b7d4421dd5a0666a63f8ca79be2b1e625f4c8f4c96b71"},
{"source": "app.adapters.network.doh", "target": "urllib.request", "kind": "raw_transport", "fingerprint": "6f5f5fd3da02a9e780ea5e7cc1e47bd962314a1a358f14b4ee698485f96ab52b"},
{"source": "app.adapters.network.http", "target": "httpx", "kind": "raw_transport", "fingerprint": "c00458c868240f1adfe6278ba22bd5c4782e9bd2fcf896114c2288bf82981ee3"},
{"source": "app.adapters.network.http", "target": "httpx2", "kind": "raw_transport", "fingerprint": "73879afc31531cc27241f21e0edf3b8e493a7d9aef973d0c2e28165d006e631d"},
{"source": "app.adapters.network.http", "target": "httpx2", "kind": "raw_transport", "fingerprint": "cf347be42a531743cdae292eecd31f714be16ad13584a88f49a9102688262682"},
{"source": "app.adapters.network.http", "target": "requests", "kind": "raw_transport", "fingerprint": "c95df710a8536328f5ee005c0ce065b9528f2f4ebae1f077315102d8c0f4c01f"},
{"source": "app.adapters.network.ip", "target": "socket.gethostbyname", "kind": "protocol_operation", "fingerprint": "a43e8969e2f26546fcf925b258728b309f1b9b966ff3c7b48a64273b8c82d048"},
{"source": "app.adapters.network.resolver", "target": "socket.getaddrinfo", "kind": "protocol_operation", "fingerprint": "1b1ce6763d730d7aa9c35d32a668560013c939467dcb3087357ffa53653ff306"}

View File

@@ -98,7 +98,8 @@ FROZEN_EGRESS_FINGERPRINT_BY_EDGE = {
("app.adapters.network.doh", "socket.getaddrinfo"): "4ff03419dfacc6bf582b7d4421dd5a0666a63f8ca79be2b1e625f4c8f4c96b71",
("app.adapters.network.doh", "urllib.request"): "6f5f5fd3da02a9e780ea5e7cc1e47bd962314a1a358f14b4ee698485f96ab52b",
("app.adapters.network.http", "httpx"): "c00458c868240f1adfe6278ba22bd5c4782e9bd2fcf896114c2288bf82981ee3",
("app.adapters.network.http", "httpx2"): "73879afc31531cc27241f21e0edf3b8e493a7d9aef973d0c2e28165d006e631d",
# 池构造移入同一 owner 的专用函数以支持恢复;调用种类、数量与外连边均未增加。
("app.adapters.network.http", "httpx2"): "cf347be42a531743cdae292eecd31f714be16ad13584a88f49a9102688262682",
("app.adapters.network.http", "requests"): "c95df710a8536328f5ee005c0ce065b9528f2f4ebae1f077315102d8c0f4c01f",
("app.adapters.network.http", "urllib3"): "66cbd8ec4e7552bd458db0baada30f1953e6d0493793822d23a2199567bca98a",
("app.adapters.network.ip", "socket.gethostbyname"): "a43e8969e2f26546fcf925b258728b309f1b9b966ff3c7b48a64273b8c82d048",

View File

@@ -0,0 +1,416 @@
"""
共享异步 HTTP 池的连续超时恢复、在途响应和关闭边界回归。
"""
import asyncio
from collections.abc import AsyncIterator
from typing import Any, Optional
import httpx2
import pytest
import pytest_asyncio
from httpcore2 import AsyncMockBackend
from app.adapters.network import http as http_module
from app.adapters.network.http import AsyncRequestUtils
URL = "https://raw.githubusercontent.com/demo/repo/main/package.v3.json"
class _ResponseStream(httpx2.AsyncByteStream):
"""
记录响应体的生命周期,以便发现旧池关闭早于响应退出。
"""
def __init__(self, transport: "_ControlledTransport") -> None:
"""
把当前响应登记为仍在读取的流。
"""
self.transport = transport
self.closed = False
transport.streams += 1
async def __aiter__(self) -> AsyncIterator[bytes]:
"""
返回离线索引载荷,保持真实 HTTPX 流式读取路径。
"""
assert not self.transport.closed
yield b'{"Demo":{"version":"1.0"}}'
async def aclose(self) -> None:
"""
记录响应流关闭,允许断言它先于退役池关闭。
"""
if not self.closed:
self.closed = True
self.transport.streams -= 1
class _ControlledTransport(httpx2.AsyncBaseTransport):
"""
在传输边界注入取池超时,不访问真实代理或网络。
"""
def __init__(self, **kwargs: Any) -> None:
"""
保存新池的完整配置和请求记录,默认模拟无法分配连接。
"""
self.kwargs = kwargs
self.error: Optional[type[httpx2.RequestError]] = httpx2.PoolTimeout
self.requests: list[httpx2.Request] = []
self.streams = 0
self.closed = False
self.close_calls = 0
self.close_error = False
self.release_requests: Optional[asyncio.Event] = None
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
"""
失败时保留真实异常类别,成功时交给真实客户端消费响应体。
"""
assert not self.closed
self.requests.append(request)
if self.release_requests is not None:
await self.release_requests.wait()
if self.error is not None:
raise self.error("injected pool state", request=request)
return httpx2.Response(200, stream=_ResponseStream(self), request=request)
async def aclose(self) -> None:
"""
验证活跃响应未被中途关闭,并允许注入一次关闭失败。
"""
self.close_calls += 1
if self.close_error:
self.close_error = False
raise RuntimeError("injected close failure")
assert self.streams == 0
self.closed = True
@pytest_asyncio.fixture
async def pools(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[list[_ControlledTransport]]:
"""
每个事件循环创建独立受控池,并在用例结束后回收全部托管任务。
"""
created: list[_ControlledTransport] = []
def create(**kwargs: Any) -> _ControlledTransport:
"""
首个池模拟故障,替换池默认可正常返回响应。
"""
transport = _ControlledTransport(**kwargs)
if created:
transport.error = None
created.append(transport)
return transport
monkeypatch.setattr(http_module.httpx2, "AsyncHTTPTransport", create)
try:
yield created
finally:
await http_module.aclose_shared_async_transports()
await asyncio.sleep(0)
loop = asyncio.get_running_loop()
assert not http_module._retired_async_transports.get(loop)
assert not any(task.get_loop() is loop for task in http_module._pending_eviction_tasks)
async def _timeout(request: AsyncRequestUtils, *, stream: bool = False, method: str = "GET") -> None:
"""
通过公开请求入口触发一次取池超时,并核对异常传播。
"""
with pytest.raises(httpx2.PoolTimeout):
if stream:
async with request.get_stream(URL, raise_exception=True):
pytest.fail("故障池不应返回响应")
else:
await request.request(method, URL, raise_exception=True)
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [False, True])
async def test_repeated_pool_timeouts_replace_only_failed_pool(pools, stream: bool) -> None:
"""
普通和流式请求均在连续三次取池超时后恢复,保留代理及池限制配置。
"""
request = AsyncRequestUtils(proxies="http://proxy.example:7890", timeout=15)
for _ in range(2):
await _timeout(request, stream=stream)
assert len(pools) == 1
await _timeout(request, stream=stream)
await asyncio.sleep(0)
assert len(pools) == 2
assert pools[0].closed
assert len(pools[0].requests) == 3
assert pools[0].kwargs.keys() == pools[1].kwargs.keys()
for key in ("proxy", "verify", "http2", "trust_env"):
assert pools[0].kwargs[key] == pools[1].kwargs[key]
assert vars(pools[0].kwargs["limits"]) == vars(pools[1].kwargs["limits"])
async with request.get_stream(URL, raise_exception=True) as response:
assert await response.aread() == b'{"Demo":{"version":"1.0"}}'
assert len(pools) == 2
@pytest.mark.asyncio
async def test_retired_pool_drains_stream_and_limits_generations(pools) -> None:
"""
退役池保留在途流;旧代未退出时,新代再超时也不会增加第三代。
"""
request = AsyncRequestUtils()
await _timeout(request)
pools[0].error = None
async with request.get_stream(URL, raise_exception=True) as original:
pools[0].error = httpx2.PoolTimeout
for _ in range(3):
await _timeout(request)
await asyncio.sleep(0)
assert len(pools) == 2
assert not pools[0].closed
pools[1].error = httpx2.PoolTimeout
for _ in range(4):
await _timeout(request)
assert len(pools) == 2
assert await original.aread() == b'{"Demo":{"version":"1.0"}}'
await asyncio.sleep(0)
assert pools[0].closed
await _timeout(request)
assert len(pools) == 3
assert await request.get(URL) is not None
@pytest.mark.asyncio
async def test_success_resets_consecutive_pool_failures(pools) -> None:
"""
有请求成功获得连接就清零失败计数,避免零散拥塞触发重建。
"""
request = AsyncRequestUtils()
await _timeout(request)
await _timeout(request)
pools[0].error = None
assert await request.get(URL) is not None
pools[0].error = httpx2.PoolTimeout
await _timeout(request)
await _timeout(request)
assert len(pools) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("error", [httpx2.ConnectTimeout, httpx2.ReadTimeout])
async def test_other_timeouts_do_not_replace_pool(pools, error) -> None:
"""
网络连接和读取超时维持既有语义,不作为池状态故障触发重建。
"""
request = AsyncRequestUtils()
await _timeout(request)
pools[0].error = error
for _ in range(4):
assert await request.get(URL) is None
pools[0].error = httpx2.PoolTimeout
await _timeout(request)
await _timeout(request)
assert len(pools) == 1
@pytest.mark.asyncio
async def test_post_is_not_replayed_and_false_exception_contract_is_preserved(pools) -> None:
"""
恢复只影响后续请求,POST 不重放,默认失败仍返回 None。
"""
request = AsyncRequestUtils()
for _ in range(3):
assert await request.request("POST", URL) is None
assert len(pools[0].requests) == 3
assert all(request.method == "POST" for request in pools[0].requests)
assert await request.request("POST", URL) is not None
assert len(pools[1].requests) == 1
@pytest.mark.asyncio
async def test_caller_owned_client_is_not_replaced(pools) -> None:
"""
调用方自管客户端的池和关闭权保持不变。
"""
transport = _ControlledTransport()
async with httpx2.AsyncClient(transport=transport) as client:
request = AsyncRequestUtils(client=client)
for _ in range(4):
await _timeout(request)
assert not transport.closed
assert not pools
assert transport.closed
@pytest.mark.asyncio
async def test_close_failure_retains_old_generation_until_shutdown_retry(pools) -> None:
"""
退役关闭失败时保留 owner 阻止继续轮换,并由关闭入口重试回收。
"""
request = AsyncRequestUtils()
await _timeout(request)
pools[0].close_error = True
await _timeout(request)
await _timeout(request)
await asyncio.sleep(0)
assert not pools[0].closed
pools[1].error = httpx2.PoolTimeout
for _ in range(3):
await _timeout(request)
assert len(pools) == 2
await http_module.aclose_shared_async_transports()
assert pools[0].closed
assert pools[0].close_calls == 2
@pytest.mark.asyncio
async def test_registry_shutdown_keeps_retired_pool_for_explicit_close(pools, monkeypatch) -> None:
"""
任务登记器停止接收后,归还借用仍保留原异常,并把旧池交给显式关闭入口。
"""
from app.runtime.tasks import TaskRegistry
registry = TaskRegistry()
assert await registry.shutdown()
monkeypatch.setattr(http_module, "get_task_registry", lambda: registry)
request = AsyncRequestUtils()
for _ in range(3):
await _timeout(request)
assert not pools[0].closed
assert await request.get(URL) is not None
await http_module.aclose_shared_async_transports()
assert pools[0].closed
@pytest.mark.asyncio
async def test_plugin_index_recovers_through_real_streaming_entry(pools, monkeypatch) -> None:
"""
插件索引调用真实异步读取路径,在共享池更换后下一次查询成功。
"""
from app.adapters.external.plugin.client import PluginMarketTransport
monkeypatch.setattr(
PluginMarketTransport,
"_build_github_request_strategies",
lambda **_kwargs: [("代理", URL, {"proxies": "http://proxy.example:7890"})],
)
transport = PluginMarketTransport()
for _ in range(3):
with pytest.raises(RuntimeError, match="连接失败"):
await transport._fetch_plugin_index_async(URL, None)
assert await transport._fetch_plugin_index_async(URL, None) == {"Demo": {"version": "1.0"}}
assert len(pools) == 2
@pytest.mark.asyncio
async def test_concurrent_old_generation_failures_do_not_rotate_replacement(pools) -> None:
"""
同一波旧代请求延迟返回超时时只轮换一次,不误退役后继池。
"""
request = AsyncRequestUtils()
await _timeout(request)
pools[0].release_requests = asyncio.Event()
tasks = [asyncio.create_task(request.request("POST", URL)) for _ in range(8)]
await asyncio.sleep(0)
assert len(pools[0].requests) == 9
pools[0].release_requests.set()
assert await asyncio.gather(*tasks) == [None] * 8
await asyncio.sleep(0)
assert len(pools) == 2
assert pools[0].closed
assert len(pools[1].requests) == 0
assert await request.get(URL) is not None
@pytest.mark.asyncio
async def test_cancelled_stream_releases_retired_pool(pools) -> None:
"""
持有旧流的任务被取消后也会归还借用,退役池最终关闭。
"""
request = AsyncRequestUtils()
await _timeout(request)
pools[0].error = None
entered = asyncio.Event()
async def hold_stream() -> None:
"""
持有真实客户端流上下文直到测试取消任务。
"""
async with request.get_stream(URL, raise_exception=True):
entered.set()
await asyncio.Event().wait()
task = asyncio.create_task(hold_stream())
try:
await entered.wait()
pools[0].error = httpx2.PoolTimeout
for _ in range(3):
await _timeout(request)
assert not pools[0].closed
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
await asyncio.sleep(0)
assert pools[0].closed
@pytest.mark.asyncio
async def test_recovery_isolated_by_transport_configuration(pools) -> None:
"""
一个配置桶退役时,其他配置仍复用原池。
"""
failing = AsyncRequestUtils(http2=True)
healthy = AsyncRequestUtils(http2=False)
await _timeout(failing)
assert await healthy.get(URL) is not None
await _timeout(failing)
await _timeout(failing)
assert len(pools) == 3
assert await healthy.get(URL) is not None
assert len(pools[1].requests) == 2
assert not pools[1].closed
@pytest.mark.asyncio
async def test_real_connect_pool_recovers_without_interrupting_active_response(monkeypatch) -> None:
"""
用真实 HTTPX/httpcore CONNECT 单连接池制造排队超时,验证新池恢复及旧流排空。
只在网络后端回放字节,不替换池分配、超时异常或客户端关闭逻辑。
"""
original_transport = httpx2.AsyncHTTPTransport
created: list[httpx2.AsyncHTTPTransport] = []
def create(**kwargs: Any) -> httpx2.AsyncHTTPTransport:
"""
保留真实代理池,仅将 TCP/TLS I/O 替换为离线 CONNECT 响应。
"""
transport = original_transport(**kwargs)
transport._pool._network_backend = AsyncMockBackend([
b"HTTP/1.1 200 Connection established\r\n\r\n",
b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello",
])
created.append(transport)
return transport
monkeypatch.setattr(http_module.httpx2, "AsyncHTTPTransport", create)
request = AsyncRequestUtils(
proxies="http://proxy.example:7890", http2=False, max_connections=1,
)
try:
async with request.get_stream(URL, raise_exception=True) as original:
for _ in range(3):
with pytest.raises(httpx2.PoolTimeout):
await request.request(
"GET", URL, raise_exception=True,
timeout=httpx2.Timeout(1, pool=0.01),
)
assert len(created) == 2
assert len(created[0]._pool.connections) == 1
assert await request.get(URL) == "hello"
assert await original.aread() == b"hello"
await asyncio.sleep(0)
assert created[0]._pool.connections == []
finally:
await http_module.aclose_shared_async_transports()
assert created[1]._pool.connections == []