Skip to content

Commit 63a623e

Browse files
refactor: centralize LLM HTTP client lifecycle
1 parent 67c4bf6 commit 63a623e

4 files changed

Lines changed: 180 additions & 12 deletions

File tree

scripts/bazi_engine/_http.py

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,95 @@
1-
"""共享 HTTP 客户端 — 复用连接池,避免每个模块各自创建"""
2-
from contextlib import contextmanager
1+
"""共享 HTTP 客户端和调用级超时控制。"""
2+
import threading
3+
from collections.abc import Iterator
4+
from contextlib import asynccontextmanager, contextmanager
35

46
import httpx
57

68
_client: httpx.Client | None = None
9+
_async_client: httpx.AsyncClient | None = None
10+
_client_lock = threading.Lock()
711

812

9-
@contextmanager
10-
def shared_client(timeout: float = 60.0):
11-
"""获取共享的 httpx.Client(懒加载,单例,contextmanager 兼容 with 语句)"""
13+
class _TimeoutBoundClient:
14+
"""Apply a timeout to one call without mutating the shared connection pool."""
15+
16+
def __init__(self, client: httpx.Client, timeout: float):
17+
self._client = client
18+
self._timeout = timeout
19+
20+
def post(self, url: str, **kwargs) -> httpx.Response:
21+
kwargs.setdefault("timeout", self._timeout)
22+
return self._client.post(url, **kwargs)
23+
24+
def stream(self, method: str, url: str, **kwargs):
25+
kwargs.setdefault("timeout", self._timeout)
26+
return self._client.stream(method, url, **kwargs)
27+
28+
29+
class _AsyncTimeoutBoundClient:
30+
"""Async counterpart that shares one connection pool across chat streams."""
31+
32+
def __init__(self, client: httpx.AsyncClient, timeout: float):
33+
self._client = client
34+
self._timeout = timeout
35+
36+
async def post(self, url: str, **kwargs) -> httpx.Response:
37+
kwargs.setdefault("timeout", self._timeout)
38+
return await self._client.post(url, **kwargs)
39+
40+
def stream(self, method: str, url: str, **kwargs):
41+
kwargs.setdefault("timeout", self._timeout)
42+
return self._client.stream(method, url, **kwargs)
43+
44+
45+
def _get_client() -> httpx.Client:
1246
global _client
13-
if _client is None:
14-
_client = httpx.Client(timeout=timeout)
15-
yield _client
47+
with _client_lock:
48+
if _client is None or _client.is_closed:
49+
_client = httpx.Client(
50+
limits=httpx.Limits(max_connections=12, max_keepalive_connections=6),
51+
)
52+
return _client
53+
54+
55+
def _get_async_client() -> httpx.AsyncClient:
56+
global _async_client
57+
if _async_client is None or _async_client.is_closed:
58+
_async_client = httpx.AsyncClient(
59+
limits=httpx.Limits(max_connections=12, max_keepalive_connections=6),
60+
)
61+
return _async_client
62+
63+
64+
def close_shared_client() -> None:
65+
"""Close the connection pool during application shutdown."""
66+
global _client
67+
with _client_lock:
68+
client, _client = _client, None
69+
if client is not None and not client.is_closed:
70+
client.close()
71+
72+
73+
async def close_shared_clients() -> None:
74+
"""Close both pools during application shutdown."""
75+
global _async_client
76+
close_shared_client()
77+
async_client, _async_client = _async_client, None
78+
if async_client is not None and not async_client.is_closed:
79+
await async_client.aclose()
80+
81+
82+
@contextmanager
83+
def shared_client(timeout: float = 60.0) -> Iterator[_TimeoutBoundClient]:
84+
"""Yield a shared pool with a timeout scoped to this individual request."""
85+
if timeout <= 0:
86+
raise ValueError("timeout must be positive")
87+
yield _TimeoutBoundClient(_get_client(), timeout)
88+
89+
90+
@asynccontextmanager
91+
async def shared_async_client(timeout: float = 60.0):
92+
"""Yield the async connection pool with a timeout scoped to this request."""
93+
if timeout <= 0:
94+
raise ValueError("timeout must be positive")
95+
yield _AsyncTimeoutBoundClient(_get_async_client(), timeout)

scripts/bazi_engine/api.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import threading
2323
import time
2424
import uuid
25-
from contextlib import suppress
25+
from contextlib import asynccontextmanager, suppress
2626
from datetime import datetime as dt
2727
from datetime import timedelta
2828
from pathlib import Path
@@ -34,6 +34,7 @@
3434
from fastapi.staticfiles import StaticFiles
3535
from pydantic import BaseModel, Field
3636

37+
from ._http import close_shared_clients
3738
from ._version import __version__
3839
from .chart import build_chart
3940

@@ -77,7 +78,15 @@ class ActivationCodeRequest(BaseModel):
7778
code: str = Field(min_length=1, max_length=80)
7879

7980

80-
app = FastAPI(title="八字排盘引擎", version=__version__)
81+
@asynccontextmanager
82+
async def lifespan(_app: FastAPI):
83+
try:
84+
yield
85+
finally:
86+
await close_shared_clients()
87+
88+
89+
app = FastAPI(title="八字排盘引擎", version=__version__, lifespan=lifespan)
8190

8291
# 前端页面路径(支持环境变量覆盖,打包部署时可指定)
8392
_frontend_env = os.getenv("FRONTEND_DIR", "")

scripts/bazi_engine/chat.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@
1212

1313
import httpx
1414

15+
from ._deepseek_config import DEEPSEEK_API_URL, DEEPSEEK_KEY, DEEPSEEK_MODEL
16+
from ._http import shared_async_client
17+
1518
# ═══════════════════════════════════════════════════════════════
1619
# 配置
1720
# ═══════════════════════════════════════════════════════════════
18-
from ._deepseek_config import DEEPSEEK_API_URL, DEEPSEEK_KEY, DEEPSEEK_MODEL
1921

2022
_ACTIVATION_FILE = Path(__file__).resolve().parent / "activation_codes.json"
2123
_RUNTIME_DATA_LOCK = threading.RLock()
@@ -334,7 +336,7 @@ async def call_deepseek_stream(messages: list[dict]) -> AsyncGenerator[str]:
334336

335337
try:
336338
async with (
337-
httpx.AsyncClient(timeout=60.0) as client,
339+
shared_async_client(60.0) as client,
338340
client.stream("POST", DEEPSEEK_API_URL, json=payload, headers=headers) as resp,
339341
):
340342
if resp.status_code != 200:

scripts/tests/test_http.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Shared HTTP client lifecycle and timeout tests."""
2+
3+
import asyncio
4+
5+
import httpx
6+
import pytest
7+
from fastapi.testclient import TestClient
8+
9+
10+
def test_shared_client_keeps_timeouts_scoped_to_each_request(monkeypatch):
11+
import bazi_engine._http as http_module
12+
13+
seen_timeouts = []
14+
15+
def handler(request: httpx.Request) -> httpx.Response:
16+
seen_timeouts.append(request.extensions["timeout"])
17+
return httpx.Response(200, json={"ok": True})
18+
19+
raw_client = httpx.Client(transport=httpx.MockTransport(handler))
20+
monkeypatch.setattr(http_module, "_client", raw_client)
21+
22+
with http_module.shared_client(5.0) as client:
23+
assert client.post("https://example.test/first").status_code == 200
24+
with http_module.shared_client(12.0) as client:
25+
assert client.post("https://example.test/second").status_code == 200
26+
27+
assert seen_timeouts[0]["read"] == 5.0
28+
assert seen_timeouts[1]["read"] == 12.0
29+
http_module.close_shared_client()
30+
31+
32+
def test_shared_client_rejects_non_positive_timeout():
33+
import bazi_engine._http as http_module
34+
35+
with pytest.raises(ValueError, match="timeout must be positive"), http_module.shared_client(0):
36+
pass
37+
38+
39+
def test_api_lifespan_closes_shared_http_client(monkeypatch):
40+
import bazi_engine._http as http_module
41+
from bazi_engine.api import app
42+
43+
raw_client = httpx.Client(transport=httpx.MockTransport(lambda _request: httpx.Response(200)))
44+
raw_async_client = httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(200)))
45+
monkeypatch.setattr(http_module, "_client", raw_client)
46+
monkeypatch.setattr(http_module, "_async_client", raw_async_client)
47+
48+
with TestClient(app) as client:
49+
assert client.get("/api/health").status_code == 200
50+
51+
assert raw_client.is_closed
52+
assert raw_async_client.is_closed
53+
54+
55+
def test_shared_async_client_keeps_timeouts_scoped_to_each_request(monkeypatch):
56+
import bazi_engine._http as http_module
57+
58+
seen_timeouts = []
59+
60+
async def handler(request: httpx.Request) -> httpx.Response:
61+
seen_timeouts.append(request.extensions["timeout"])
62+
return httpx.Response(200, json={"ok": True})
63+
64+
raw_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
65+
monkeypatch.setattr(http_module, "_async_client", raw_client)
66+
67+
async def make_calls():
68+
async with http_module.shared_async_client(7.0) as client:
69+
assert (await client.post("https://example.test/first")).status_code == 200
70+
async with http_module.shared_async_client(13.0) as client:
71+
assert (await client.post("https://example.test/second")).status_code == 200
72+
73+
asyncio.run(make_calls())
74+
75+
assert seen_timeouts[0]["read"] == 7.0
76+
assert seen_timeouts[1]["read"] == 13.0
77+
asyncio.run(http_module.close_shared_clients())

0 commit comments

Comments
 (0)