From 5dd336b0110b408a111eccb9f0fc2aab48920d38 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 8 Jul 2026 10:40:14 +0200 Subject: [PATCH] =?UTF-8?q?fix(backend):=20Kimi=20OAuth=20=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E7=BA=A7=20httpx=20=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E6=94=B9=E7=94=9F=E6=88=90=E5=99=A8=E4=BE=9D=E8=B5=96=E4=BF=AE?= =?UTF-8?q?=E6=B3=84=E6=BC=8F=EF=BC=88CR-H2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/tests/test_kimi_oauth_endpoints.py | 44 ++++++++++++++++++++- apps/api/ww_api/routers/kimi_oauth.py | 20 +++++++++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/apps/api/tests/test_kimi_oauth_endpoints.py b/apps/api/tests/test_kimi_oauth_endpoints.py index 1e1887e..a0e577b 100644 --- a/apps/api/tests/test_kimi_oauth_endpoints.py +++ b/apps/api/tests/test_kimi_oauth_endpoints.py @@ -150,7 +150,8 @@ def _make_client( app.dependency_overrides[get_job_repo] = lambda: job_repo app.dependency_overrides[get_session] = lambda: FakeSession() 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) @@ -263,6 +264,47 @@ def test_poll_loop_does_not_hold_db_session(monkeypatch: pytest.MonkeyPatch) -> 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: enc_key = Fernet.generate_key().decode() store = FakeCredentialStore() diff --git a/apps/api/ww_api/routers/kimi_oauth.py b/apps/api/ww_api/routers/kimi_oauth.py index 619d52e..d2a7e78 100644 --- a/apps/api/ww_api/routers/kimi_oauth.py +++ b/apps/api/ww_api/routers/kimi_oauth.py @@ -18,6 +18,7 @@ from __future__ import annotations import asyncio import uuid +from collections.abc import AsyncIterator from typing import Annotated, Any import httpx @@ -72,8 +73,23 @@ _JOB_KIND_KIMI_OAUTH = "kimi_oauth" _MAX_POLL_ATTEMPTS = 200 +#: 请求/后台 HTTP 客户端超时(秒)。 +_HTTP_TIMEOUT_SECONDS = 30.0 + + 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 退避每次增大的间隔量(秒)。 @@ -163,7 +179,7 @@ async def start_oauth( job_repo: JobRepoDep, session: SessionDep, session_factory: SessionFactoryDep, - http: Annotated[AsyncHttpClient, Depends(_default_http_client)], + http: Annotated[AsyncHttpClient, Depends(_http_client)], ) -> OAuthStartResponse: """发起 device authorization:返回 202 + user_code/verification_uri,后台轮询 token。