Archived
拆掉「同步调异步 / 异步桥同步」两类垫片的第二半(HTTP 线 B2 见前序提交)。 worker 侧此前是「起线程跑同步函数、函数体内部再用 asyncio.to_thread 把活塞给 另一条循环」——线程与协程互等,取消/暂停只能靠轮询。本轮把 worker 自身变成 协程,由 _start_task 直接挂到应用事件循环上,桥接层随之失去存在理由。 改动(按子系统): - music_downloader:downloader / _aria2 / _concurrency / cover / _http / __main__ 全链路协程化。per-file 锁改 asyncio.Lock;计数闸改 「active 计数 + await asyncio.sleep(0.5)」的可取消等待(原阻塞式 with 会让 已取消任务仍要等全局名额腾出,卡死并拖后腿退款);aria2p 的同步 RPC 仍经 to_thread,轮询改 asyncio.sleep;新增 core.http_client.iter_chunks,把**流式 读取期**的 aiohttp.ClientError 收敛成 UpstreamNetworkError(请求建立阶段早已 有这条路径,读取阶段此前没有,断流会绕过调用方的 UpstreamError 分类)。 删除 get_http_session / _SESSION_LOCAL。 models._RetryFromScratch 不再继承 requests.RequestException 而继承 Exception: 它是「续传起点失效、残片已清、从头重下」,既不是确定性失败 (DownloadException),也不是残片已保留的网络失败(UpstreamError),必须独立 成类且在 except 链中先于二者捕获。 - music_api:podcast.py 全部函数协程化(直链解析内部改 await NeteaseAPI.get_song_url);compat.py 删除——16 个死掉的同步门面早已 清空,文件只剩空壳。 - services:_taskcore / _zipcore / batch / downloads / podcast / tasks 协程化。 threading.Thread → loop.create_task(任务句柄字段 worker_thread → worker_task),ThreadPoolExecutor → asyncio.gather + asyncio.Semaphore, 终态函数与结算回调全部改 await;监控线程里「worker 已死」判定改 task.done()。 删除 services/streaming.py(同步流垫片,唯一消费者已迁到 core.http_client.open)。zipfile 实例仍非线程安全,故「下载」并行、 「入包」严格串行。 - web:路由层 130+ 处 to_thread 拆除——被包住的目标自己已是协程,再包一层只是 把协程丢进线程里跑一圈。tasks_registry.start_task 直接 create_task。 web/app.py 关闭段去掉 async_bridge.shutdown()(不再有第二条约循环)。 web/routers/download.py 的播客端点补上 3 处漏掉的 await (extract_podcast_ref / get_radio_detail)——漏掉时不报错、只静默返回协程对象, 靠 pyright 抓出来后修正。 留在 to_thread 上的都是**正当的阻塞卸载**:同步 SQLite 落库与账本结算、 mutagen 全量重写标签、zipfile 整段拷贝、shutil.rmtree、aria2p 的同步 RPC、 PBKDF2 的 10 万次迭代。ruff 对残留的本地元数据操作(exists/stat/os.replace/ 往 .part 顺序写分块)按「微秒级、卸载的调度开销大于操作本身」放行,pyproject 逐条注明理由。 取消/暂停仍由 threading.Event 承载:置位方是请求线程/监控线程,读取方是 worker 协程,只有「置位」与「轮询读取」两种操作,既不需要跨循环唤醒也不会卡住事件 循环——比 asyncio.Event 更省心(ASYNC110 同理按此豁免)。 测试:10 个文件的替身边界同步迁移——网络替身从 core.sync_http 的 get_http_session 换成 downloader/服务模块的 _http.open + **真实** iter_chunks (顺带钉住读取期异常收敛);`_run` 改为在独立线程里 asyncio.run(worker(...)); aria2 的假时钟从 time.sleep 换成 _POLL_INTERVAL=0;monitor 的线程句柄替身换成 done() 可控的轻量替身(asyncio.Task 的构造需要运行中的事件循环,而监控循环跑在 常驻线程里);podcast / 封面 / 结算包装等替身一律改 async。 门禁:ruff 全过 / pyright 0-0 / pytest 383 项全绿(0 失败)。 下一步 B2:auth_system 12 个模块改 aiosqlite 背板、web 层剩余 to_thread 收尾; B3:删 core/sync_http.py、core/async_bridge.py、core/http_errors.py(现仅 sync_http 自用)。
308 lines
12 KiB
Python
308 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""异步 HTTP 客户端(aiohttp):连接池 + 有界重试 + 响应体上限。
|
||
|
||
替代旧的长连接 `requests.Session`/裸 `requests.get/post`:
|
||
- 单一 `ClientSession` 复用 TCP 连接(keep-alive),连接池限制防上游打爆;
|
||
- 重试口径与旧实现一致:网络异常/5xx 退避重试一次,4xx(429/460 风控)**不**重试;
|
||
- 响应体超过 `max_bytes` 直接拒绝(防止被劫持/异常上游的超大 body 进内存)。
|
||
|
||
异常层次(调用方按需映射为业务异常,例如 music_api.APIException):
|
||
UpstreamError
|
||
├── UpstreamNetworkError 网络/超时(可重试)
|
||
├── UpstreamHTTPError 非 2xx(5xx 可重试,4xx 不可)
|
||
└── UpstreamTooLarge 响应体超限
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
from collections.abc import AsyncGenerator, Mapping
|
||
from contextlib import asynccontextmanager
|
||
from dataclasses import dataclass, field
|
||
from typing import Any
|
||
|
||
import aiohttp
|
||
|
||
DEFAULT_TIMEOUT = 30
|
||
DEFAULT_MAX_BYTES = 8 * 1024 * 1024
|
||
|
||
# UA 与 Referer 由调用方传入(music_api.APIConstants)
|
||
_EMPTY: Mapping[str, str] = {}
|
||
|
||
|
||
class UpstreamError(Exception):
|
||
"""上游请求错误基类。"""
|
||
|
||
|
||
class UpstreamNetworkError(UpstreamError):
|
||
"""网络异常/超时(可重试)。"""
|
||
|
||
|
||
class UpstreamHTTPError(UpstreamError):
|
||
"""上游返回非 2xx 状态码。"""
|
||
|
||
def __init__(self, status: int, message: str = ''):
|
||
super().__init__(f"HTTP {status} {message}".strip())
|
||
self.status = status
|
||
|
||
|
||
class UpstreamTooLarge(UpstreamError):
|
||
"""响应体超过大小上限。"""
|
||
|
||
|
||
@dataclass
|
||
class HTTPResult:
|
||
"""一次请求的完整结果(供需要状态码/头/cookie 的调用点使用)。"""
|
||
|
||
status: int
|
||
url: str
|
||
text: str
|
||
headers: Mapping[str, str] = field(default_factory=dict)
|
||
cookies: Mapping[str, str] = field(default_factory=dict)
|
||
|
||
def json(self) -> Any:
|
||
return json.loads(self.text)
|
||
|
||
|
||
class AsyncHTTPClient:
|
||
"""带连接池的异步 HTTP 客户端。"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
total_timeout: float = DEFAULT_TIMEOUT,
|
||
max_bytes: int = DEFAULT_MAX_BYTES,
|
||
pool_limit: int = 100,
|
||
pool_per_host: int = 32,
|
||
user_agent: str | None = None,
|
||
referer: str | None = None,
|
||
):
|
||
self._total_timeout = total_timeout
|
||
self._max_bytes = max_bytes
|
||
self._pool_limit = pool_limit
|
||
self._pool_per_host = pool_per_host
|
||
self._default_headers: dict[str, str] = {}
|
||
if user_agent:
|
||
self._default_headers['User-Agent'] = user_agent
|
||
if referer:
|
||
self._default_headers['Referer'] = referer
|
||
self._session: aiohttp.ClientSession | None = None
|
||
self._loop_sessions: dict[int, aiohttp.ClientSession] = {}
|
||
self._lock = asyncio.Lock()
|
||
|
||
async def _session_get(self) -> aiohttp.ClientSession:
|
||
if self._session is not None and not self._session.closed:
|
||
return self._session
|
||
async with self._lock:
|
||
if self._session is None or self._session.closed:
|
||
connector = aiohttp.TCPConnector(
|
||
limit=self._pool_limit,
|
||
limit_per_host=self._pool_per_host,
|
||
ttl_dns_cache=300,
|
||
)
|
||
self._session = aiohttp.ClientSession(
|
||
connector=connector,
|
||
timeout=aiohttp.ClientTimeout(total=self._total_timeout),
|
||
headers=dict(self._default_headers),
|
||
)
|
||
return self._session
|
||
|
||
async def _session_for_loop(self) -> aiohttp.ClientSession:
|
||
"""按事件循环隔离 session。
|
||
|
||
同一进程内可能有两个 loop(应用主 loop + 同步桥的后台 loop)。
|
||
aiohttp 的 ClientSession 不能跨 loop 复用,故按 loop 分别持有。
|
||
"""
|
||
key = id(asyncio.get_running_loop())
|
||
sess = self._loop_sessions.get(key)
|
||
if sess is not None and not sess.closed:
|
||
return sess
|
||
async with self._lock:
|
||
sess = self._loop_sessions.get(key)
|
||
if sess is None or sess.closed:
|
||
connector = aiohttp.TCPConnector(
|
||
limit=self._pool_limit,
|
||
limit_per_host=self._pool_per_host,
|
||
ttl_dns_cache=300,
|
||
)
|
||
sess = aiohttp.ClientSession(
|
||
connector=connector,
|
||
timeout=aiohttp.ClientTimeout(total=self._total_timeout),
|
||
headers=dict(self._default_headers),
|
||
)
|
||
self._loop_sessions[key] = sess
|
||
return sess
|
||
|
||
async def close(self) -> None:
|
||
if self._session is not None and not self._session.closed:
|
||
await self._session.close()
|
||
self._session = None
|
||
for sess in self._loop_sessions.values():
|
||
if not sess.closed:
|
||
await sess.close()
|
||
self._loop_sessions.clear()
|
||
|
||
# ---------- 底层:单次请求(不重试) ----------
|
||
|
||
async def request_once(
|
||
self,
|
||
method: str,
|
||
url: str,
|
||
*,
|
||
headers: Mapping[str, str] | None = None,
|
||
cookies: Mapping[str, str] | None = None,
|
||
params: Mapping[str, Any] | None = None,
|
||
data: Any = None,
|
||
json_body: Any = None,
|
||
timeout: float | None = None,
|
||
allow_redirects: bool = True,
|
||
) -> HTTPResult:
|
||
"""发起一次请求,**不做重试**(自管重试策略的调用方用这个)。
|
||
|
||
非 2xx 一律抛 UpstreamHTTPError(含 5xx)——调用方按 .status 自行决定是否重试。
|
||
request() 在此基础上叠加「网络异常/5xx 退避重试一次」。
|
||
"""
|
||
session = await self._session_for_loop()
|
||
req_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None
|
||
try:
|
||
async with session.request(
|
||
method,
|
||
url,
|
||
headers=dict(headers) if headers else None,
|
||
cookies=dict(cookies) if cookies else None,
|
||
params=params,
|
||
data=data,
|
||
json=json_body,
|
||
timeout=req_timeout,
|
||
allow_redirects=allow_redirects,
|
||
) as resp:
|
||
body = await resp.read()
|
||
if len(body) > self._max_bytes:
|
||
raise UpstreamTooLarge(f"上游响应体超过上限 {self._max_bytes} 字节")
|
||
if resp.status >= 400:
|
||
raise UpstreamHTTPError(resp.status, resp.reason or '')
|
||
return HTTPResult(
|
||
status=resp.status,
|
||
url=str(resp.url),
|
||
text=body.decode(resp.charset or 'utf-8', errors='replace'),
|
||
headers=dict(resp.headers),
|
||
cookies={k: v.value for k, v in resp.cookies.items()},
|
||
)
|
||
except aiohttp.ClientError as e:
|
||
raise UpstreamNetworkError(str(e)) from e
|
||
except TimeoutError as e:
|
||
raise UpstreamNetworkError('请求超时') from e
|
||
|
||
async def _with_retry(self, fn, *, attempts: int = 2, backoff: float = 1.0) -> HTTPResult:
|
||
"""网络异常/5xx 退避重试;4xx 与超限立即抛出不重试。"""
|
||
last: UpstreamError | None = None
|
||
for i in range(attempts):
|
||
try:
|
||
return await fn()
|
||
except UpstreamHTTPError as e:
|
||
if e.status >= 500 and i + 1 < attempts:
|
||
last = e
|
||
await asyncio.sleep(backoff)
|
||
continue
|
||
raise
|
||
except UpstreamNetworkError as e:
|
||
last = e
|
||
if i + 1 < attempts:
|
||
await asyncio.sleep(backoff)
|
||
continue
|
||
raise
|
||
# 理论上不可达
|
||
raise last or UpstreamError('上游请求失败')
|
||
|
||
# ---------- 高层 API ----------
|
||
|
||
async def request(self, method: str, url: str, **kw) -> HTTPResult:
|
||
return await self._with_retry(lambda: self.request_once(method, url, **kw))
|
||
|
||
async def get_text(self, url: str, **kw) -> str:
|
||
return (await self.request('GET', url, **kw)).text
|
||
|
||
async def post_text(self, url: str, **kw) -> str:
|
||
return (await self.request('POST', url, **kw)).text
|
||
|
||
async def get_json(self, url: str, **kw) -> Any:
|
||
return (await self.request('GET', url, **kw)).json()
|
||
|
||
async def post_json(self, url: str, **kw) -> Any:
|
||
return (await self.request('POST', url, **kw)).json()
|
||
|
||
@asynccontextmanager
|
||
async def stream(
|
||
self,
|
||
method: str,
|
||
url: str,
|
||
*,
|
||
headers: Mapping[str, str] | None = None,
|
||
cookies: Mapping[str, str] | None = None,
|
||
timeout: float | None = None,
|
||
allow_redirects: bool = True,
|
||
) -> AsyncGenerator[aiohttp.ClientResponse, None]:
|
||
"""流式请求(下载/音视频代理用):yield 原始响应,边读边写不整段入内存。"""
|
||
session = await self._session_for_loop()
|
||
req_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None
|
||
try:
|
||
async with session.request(
|
||
method,
|
||
url,
|
||
headers=dict(headers) if headers else None,
|
||
cookies=dict(cookies) if cookies else None,
|
||
timeout=req_timeout,
|
||
allow_redirects=allow_redirects,
|
||
) as resp:
|
||
if resp.status >= 400:
|
||
raise UpstreamHTTPError(resp.status, resp.reason or '')
|
||
yield resp
|
||
except aiohttp.ClientError as e:
|
||
raise UpstreamNetworkError(str(e)) from e
|
||
|
||
async def open(
|
||
self,
|
||
method: str,
|
||
url: str,
|
||
*,
|
||
headers: Mapping[str, str] | None = None,
|
||
cookies: Mapping[str, str] | None = None,
|
||
timeout: float | None = None,
|
||
allow_redirects: bool = True,
|
||
) -> aiohttp.ClientResponse:
|
||
"""打开流式响应并返回**未关闭**的 ClientResponse(调用方负责 close)。
|
||
|
||
与 stream() 的区别:不把响应交给 `async with`,便于「先读状态码/头决定是否
|
||
转发,再流式读取」的代理场景(如音频 Range 代理)。
|
||
"""
|
||
session = await self._session_for_loop()
|
||
req_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None
|
||
try:
|
||
return await session.request(
|
||
method,
|
||
url,
|
||
headers=dict(headers) if headers else None,
|
||
cookies=dict(cookies) if cookies else None,
|
||
timeout=req_timeout,
|
||
allow_redirects=allow_redirects,
|
||
)
|
||
except aiohttp.ClientError as e:
|
||
raise UpstreamNetworkError(str(e)) from e
|
||
|
||
|
||
async def iter_chunks(resp: aiohttp.ClientResponse, chunk_size: int) -> AsyncGenerator[bytes, None]:
|
||
"""逐块读取流式响应体,把 aiohttp 的传输异常统一翻译为 UpstreamNetworkError。
|
||
|
||
请求建立阶段的失败由 open()/request() 翻成 UpstreamError;但**流式读取期间**
|
||
断流抛的是 aiohttp.ClientPayloadError(ClientError 子类),不在那条路径上——
|
||
调用方(音频下载、播客落盘)只认 UpstreamError 层次,故在此收敛。
|
||
"""
|
||
try:
|
||
async for chunk in resp.content.iter_chunked(chunk_size):
|
||
yield chunk
|
||
except aiohttp.ClientError as e:
|
||
raise UpstreamNetworkError(str(e)) from e
|
||
|
||
|
||
# 进程级共享客户端(UA/Referer 由 music_api 在初始化后注入)
|
||
client = AsyncHTTPClient()
|