fix(http): AsyncRequestUtils 代理请求按 host 熔断降级 h2 隧道 (#6208)

This commit is contained in:
Aqr-K
2026-07-28 18:54:16 +08:00
committed by GitHub
parent 6b4a255f26
commit 1a528c7803
2 changed files with 242 additions and 3 deletions

View File

@@ -3,10 +3,11 @@ import collections
import re
import sys
import threading
import time
import weakref
from contextlib import AsyncExitStack, contextmanager, asynccontextmanager
from pathlib import Path
from typing import Any, Optional, Tuple, Union
from typing import Any, Dict, Optional, Tuple, Union
import chardet
import httpx
@@ -72,6 +73,53 @@ _DEFAULT_MAX_CONNECTIONS = 40
_DEFAULT_KEEPALIVE_EXPIRY = 30
# 同步 requests.Session 复用连接时,遇到对端或代理关闭 keep-alive 后允许重试的方法
_REQUESTS_RETRY_IDEMPOTENT_METHODS = ("GET", "HEAD", "OPTIONS")
# 代理走 CONNECT 隧道时httpx 默认开启的 HTTP/2 多路复用会把并发请求叠加到极少数隧道上;
# 隧道被代理节点切换或空闲回收打断后,复用其上的所有请求会同时失败。按 (proxy, host) 熔断:
# 命中一次连接层失败就记录下次允许再尝试 h2 的时间戳time.monotonic 基准),冷却期内该
# (proxy, host) 的请求直接退化为 http1.1;冷却期结束后自动恢复尝试 h2。
_H2_PROXY_BREAKER_COOLDOWN = 1800 # 30 分钟
_h2_proxy_breaker_lock = threading.Lock()
_h2_proxy_retry_at: Dict[Tuple[str, str], float] = {}
# 只有这些错误是"h2 隧道被打断"的特征(对应实测日志里的 SEND_HEADERS in CLOSED、
# EndOfStream 等);超时、连接失败、代理不可达等错误换 h1 一样会发生,
# 不应触发熔断,也不值得付出一次注定同样失败的 h1 重试
_H2_TUNNEL_BREAK_ERRORS = (
httpx.RemoteProtocolError,
httpx.LocalProtocolError,
httpx.ReadError,
httpx.WriteError,
httpx.CloseError,
)
def _h2_proxy_breaker_key(proxy: str, url: str) -> Tuple[str, str]:
try:
host = httpx.URL(url).host or ""
except Exception:
host = url
return proxy, host
def _h2_proxy_allowed(proxy: Optional[str], url: str) -> bool:
"""判断给定代理 + 目标 host 当前是否允许尝试 h2未处于熔断冷却期"""
if not proxy:
return True
with _h2_proxy_breaker_lock:
retry_at = _h2_proxy_retry_at.get(_h2_proxy_breaker_key(proxy, url), 0.0)
return time.monotonic() >= retry_at
def _trip_h2_proxy_breaker(proxy: str, url: str) -> None:
"""记录一次 h2 连接层失败,熔断该 (proxy, host) 冷却期内的 h2 尝试"""
now = time.monotonic()
with _h2_proxy_breaker_lock:
# 顺手清掉已过冷却期的条目,防止长期运行下字典无限增长
for key in [k for k, retry_at in _h2_proxy_retry_at.items() if now >= retry_at]:
del _h2_proxy_retry_at[key]
_h2_proxy_retry_at[_h2_proxy_breaker_key(proxy, url)] = now + _H2_PROXY_BREAKER_COOLDOWN
# 持有 LRU 淘汰后正在异步关闭的 transport task避免 fire-and-forget 被 GC 警告
_pending_eviction_tasks: set[asyncio.Task] = set()
@@ -1087,13 +1135,48 @@ class AsyncRequestUtils:
self._client, method, url, raise_exception, **kwargs
)
# 代理走 CONNECT 隧道时 h2 多路复用容易被隧道打断放大成批量失败(见
# _h2_proxy_allowed 处注释);仅对幂等方法做"h2 失败就地降级 h1 重试"
# 避免非幂等请求在服务端可能已收到数据的情况下重复产生副作用
http2 = self._http2 and _h2_proxy_allowed(self._proxies, url)
if not (http2 and self._proxies and method.upper() in _REQUESTS_RETRY_IDEMPOTENT_METHODS):
return await self._dispatch_request(
http2, cookies_dict, method, url, raise_exception, **kwargs
)
try:
return await self._dispatch_request(
True, cookies_dict, method, url, True, **kwargs
)
except _H2_TUNNEL_BREAK_ERRORS as e:
logger.debug(f"h2 代理连接层失败,熔断 {url} 所在 host 并降级 h1 重试: {e!r}")
_trip_h2_proxy_breaker(self._proxies, url)
return await self._dispatch_request(
False, cookies_dict, method, url, raise_exception, **kwargs
)
except httpx.RequestError as e:
# 与 h2 隧道无关的失败(超时、连接失败等):不熔断也不重试,
# 恢复调用方原本的 raise_exception 语义
if raise_exception:
raise
error_msg = str(e) or f"未知网络错误 (URL: {url}, Method: {method.upper()})"
logger.debug(f"异步请求失败: {error_msg}")
return None
async def _dispatch_request(
self, http2: bool, cookies_dict: Optional[dict], method: str, url: str,
raise_exception: bool, **kwargs
) -> Optional[httpx.Response]:
"""
按给定 http2 开关构建/复用底层连接并发起请求,供 request() 的 h2/h1 熔断切换复用
"""
# 共享底层 transport连接池+TLS 复用),每次请求创建轻量 AsyncClient。
# AsyncClient 持有的 cookie jar 仅存活于本次请求 lifecycle
# 既复用握手又彻底避免 jar 跨调用累积。
transport = _get_shared_async_transport(
proxy=self._proxies,
verify=self._verify,
http2=self._http2,
http2=http2,
max_keepalive_connections=self._max_keepalive_connections,
max_connections=self._max_connections,
keepalive_expiry=self._keepalive_expiry,
@@ -1113,7 +1196,7 @@ class AsyncRequestUtils:
# 兜底:没有运行中的事件循环时,临时客户端走完即关
async with httpx.AsyncClient(
http2=self._http2,
http2=http2,
proxy=self._proxies,
timeout=self._timeout,
verify=self._verify,