fix(backend): Kimi OAuth 请求级 httpx 客户端改生成器依赖修泄漏(CR-H2)
This commit is contained in:
@@ -150,7 +150,8 @@ def _make_client(
|
|||||||
app.dependency_overrides[get_job_repo] = lambda: job_repo
|
app.dependency_overrides[get_job_repo] = lambda: job_repo
|
||||||
app.dependency_overrides[get_session] = lambda: FakeSession()
|
app.dependency_overrides[get_session] = lambda: FakeSession()
|
||||||
app.dependency_overrides[get_session_factory] = lambda: sf
|
app.dependency_overrides[get_session_factory] = lambda: sf
|
||||||
app.dependency_overrides[routers.kimi_oauth._default_http_client] = lambda: http
|
# 请求级 http 现为生成器依赖 `_http_client`(CR-H2)——override 它注 fake(否则真生成器联网)。
|
||||||
|
app.dependency_overrides[routers.kimi_oauth._http_client] = lambda: http
|
||||||
return TestClient(app)
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
@@ -263,6 +264,47 @@ def test_poll_loop_does_not_hold_db_session(monkeypatch: pytest.MonkeyPatch) ->
|
|||||||
assert "secret-ref" not in resp.text
|
assert "secret-ref" not in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_http_client_dependency_is_generator_and_closes_client(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""CR-H2:请求级 `_http_client` 是**生成器依赖**,请求结束时 aclose 客户端(不泄漏连接)。
|
||||||
|
|
||||||
|
驱动生成器一轮(enter→yield→exit),断言:是 async 生成器;yield 时未关闭;耗尽时恰好
|
||||||
|
关闭一次。RED:修复前 `_http_client` 不存在(AttributeError)。
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from ww_api.routers import kimi_oauth
|
||||||
|
|
||||||
|
closes = {"count": 0}
|
||||||
|
|
||||||
|
class _SpyClient:
|
||||||
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _SpyClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc: Any) -> None:
|
||||||
|
closes["count"] += 1
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
closes["count"] += 1
|
||||||
|
|
||||||
|
monkeypatch.setattr("ww_api.routers.kimi_oauth.httpx.AsyncClient", _SpyClient)
|
||||||
|
|
||||||
|
# 是 async 生成器依赖(有 teardown),非普通函数(普通函数依赖无 teardown → 泄漏)。
|
||||||
|
assert inspect.isasyncgenfunction(kimi_oauth._http_client)
|
||||||
|
|
||||||
|
gen = kimi_oauth._http_client()
|
||||||
|
client = await gen.__anext__()
|
||||||
|
assert client is not None
|
||||||
|
assert closes["count"] == 0 # yield 时尚未关闭
|
||||||
|
with pytest.raises(StopAsyncIteration):
|
||||||
|
await gen.__anext__()
|
||||||
|
assert closes["count"] == 1 # 请求结束恰好关闭一次
|
||||||
|
|
||||||
|
|
||||||
def test_disconnect_clears_credential(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_disconnect_clears_credential(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
enc_key = Fernet.generate_key().decode()
|
enc_key = Fernet.generate_key().decode()
|
||||||
store = FakeCredentialStore()
|
store = FakeCredentialStore()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -72,8 +73,23 @@ _JOB_KIND_KIMI_OAUTH = "kimi_oauth"
|
|||||||
_MAX_POLL_ATTEMPTS = 200
|
_MAX_POLL_ATTEMPTS = 200
|
||||||
|
|
||||||
|
|
||||||
|
#: 请求/后台 HTTP 客户端超时(秒)。
|
||||||
|
_HTTP_TIMEOUT_SECONDS = 30.0
|
||||||
|
|
||||||
|
|
||||||
def _default_http_client() -> AsyncHttpClient:
|
def _default_http_client() -> AsyncHttpClient:
|
||||||
return httpx.AsyncClient(timeout=30.0)
|
"""裸工厂:现建一个 httpx 客户端(**后台轮询**直接调用,自行在 finally 里 aclose)。"""
|
||||||
|
return httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
async def _http_client() -> AsyncIterator[AsyncHttpClient]:
|
||||||
|
"""请求级 httpx 客户端**生成器依赖**:请求结束时经 `async with` 自动 aclose(CR-H2)。
|
||||||
|
|
||||||
|
普通函数依赖无 teardown,返回的 client 永不关闭 → 每次 `/start` 泄漏一个连接。故请求路径
|
||||||
|
用此生成器依赖(后台路径仍用裸工厂 `_default_http_client`,自管生命周期)。
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SECONDS) as http:
|
||||||
|
yield http
|
||||||
|
|
||||||
|
|
||||||
# SlowDown 退避每次增大的间隔量(秒)。
|
# SlowDown 退避每次增大的间隔量(秒)。
|
||||||
@@ -163,7 +179,7 @@ async def start_oauth(
|
|||||||
job_repo: JobRepoDep,
|
job_repo: JobRepoDep,
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
session_factory: SessionFactoryDep,
|
session_factory: SessionFactoryDep,
|
||||||
http: Annotated[AsyncHttpClient, Depends(_default_http_client)],
|
http: Annotated[AsyncHttpClient, Depends(_http_client)],
|
||||||
) -> OAuthStartResponse:
|
) -> OAuthStartResponse:
|
||||||
"""发起 device authorization:返回 202 + user_code/verification_uri,后台轮询 token。
|
"""发起 device authorization:返回 202 + user_code/verification_uri,后台轮询 token。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user