Share HTTP client across all API calls for Windows performance

Single shared httpx.AsyncClient for all BooruClient instances
(Danbooru, Gelbooru, Moebooru) with connection pooling.
E621 gets its own shared client (custom User-Agent required).
Site detection also reuses the shared client.
Eliminates per-request TLS handshakes on Windows.
This commit is contained in:
pax 2026-04-05 17:22:30 -05:00
parent 4987765520
commit 96c57d16a9
3 changed files with 30 additions and 18 deletions

View File

@ -37,6 +37,9 @@ class BooruClient(ABC):
api_type: str = ""
# Shared client across all BooruClient instances for connection reuse
_shared_client: httpx.AsyncClient | None = None
def __init__(
self,
base_url: str,
@ -46,26 +49,25 @@ class BooruClient(ABC):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.api_user = api_user
self._client: httpx.AsyncClient | None = None
@property
def client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
if BooruClient._shared_client is None or BooruClient._shared_client.is_closed:
BooruClient._shared_client = httpx.AsyncClient(
headers={"User-Agent": USER_AGENT},
follow_redirects=True,
timeout=20.0,
event_hooks={"request": [self._log_request]},
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
)
return self._client
return BooruClient._shared_client
@staticmethod
async def _log_request(request: httpx.Request) -> None:
log_connection(str(request.url))
async def close(self) -> None:
if self._client and not self._client.is_closed:
await self._client.aclose()
pass # shared client stays open
@abstractmethod
async def search(

View File

@ -23,11 +23,17 @@ async def detect_site_type(
"""
url = url.rstrip("/")
async with httpx.AsyncClient(
from .base import BooruClient as _BC
# Reuse shared client for site detection
if _BC._shared_client is None or _BC._shared_client.is_closed:
_BC._shared_client = httpx.AsyncClient(
headers={"User-Agent": USER_AGENT},
follow_redirects=True,
timeout=10.0,
) as client:
timeout=20.0,
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
)
client = _BC._shared_client
if True: # keep indent level
# Try Danbooru / e621 first — /posts.json is a definitive endpoint
try:
params: dict = {"limit": 1}

View File

@ -15,19 +15,23 @@ log = logging.getLogger("booru")
class E621Client(BooruClient):
api_type = "e621"
_e621_client: httpx.AsyncClient | None = None
_e621_ua: str = ""
@property
def client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
# e621 requires a descriptive User-Agent with username
ua = USER_AGENT
if self.api_user:
ua = f"{USER_AGENT} (by {self.api_user} on e621)"
self._client = httpx.AsyncClient(
if E621Client._e621_client is None or E621Client._e621_client.is_closed or E621Client._e621_ua != ua:
E621Client._e621_ua = ua
E621Client._e621_client = httpx.AsyncClient(
headers={"User-Agent": ua},
follow_redirects=True,
timeout=20.0,
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
)
return self._client
return E621Client._e621_client
async def search(
self, tags: str = "", page: int = 1, limit: int = DEFAULT_PAGE_SIZE