Initial public release
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import httpx
|
||||
import logging
|
||||
from typing import Optional
|
||||
from core.config import Settings, get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_user_agent(settings: Optional[Settings] = None) -> str:
|
||||
if settings:
|
||||
return settings.get_user_agent()
|
||||
return get_settings().get_user_agent()
|
||||
|
||||
|
||||
class HttpClientFactory:
|
||||
_clients: dict[str, httpx.AsyncClient] = {}
|
||||
|
||||
@classmethod
|
||||
def get_client(
|
||||
cls,
|
||||
name: str = "default",
|
||||
timeout: float = 10.0,
|
||||
connect_timeout: float = 5.0,
|
||||
max_connections: int = 200,
|
||||
max_keepalive: int = 200,
|
||||
settings: Optional[Settings] = None,
|
||||
http2: bool = True,
|
||||
**kwargs
|
||||
) -> httpx.AsyncClient:
|
||||
if name not in cls._clients:
|
||||
cls._clients[name] = httpx.AsyncClient(
|
||||
http2=http2,
|
||||
timeout=httpx.Timeout(timeout, connect=connect_timeout),
|
||||
limits=httpx.Limits(
|
||||
max_connections=max_connections,
|
||||
max_keepalive_connections=max_keepalive,
|
||||
keepalive_expiry=60.0,
|
||||
),
|
||||
follow_redirects=True,
|
||||
transport=httpx.AsyncHTTPTransport(http2=http2, retries=0),
|
||||
headers={"User-Agent": _get_user_agent(settings)},
|
||||
**kwargs
|
||||
)
|
||||
return cls._clients[name]
|
||||
|
||||
@classmethod
|
||||
async def close_all(cls) -> None:
|
||||
for client in cls._clients.values():
|
||||
await client.aclose()
|
||||
cls._clients.clear()
|
||||
|
||||
|
||||
def get_http_client(
|
||||
settings: Optional[Settings] = None,
|
||||
timeout: Optional[float] = None,
|
||||
connect_timeout: Optional[float] = None,
|
||||
max_connections: Optional[int] = None,
|
||||
) -> httpx.AsyncClient:
|
||||
if settings is None:
|
||||
settings = get_settings()
|
||||
return HttpClientFactory.get_client(
|
||||
name="default",
|
||||
timeout=timeout or settings.http_timeout,
|
||||
connect_timeout=connect_timeout or settings.http_connect_timeout,
|
||||
max_connections=max_connections or settings.http_max_connections,
|
||||
max_keepalive=settings.http_max_keepalive,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
async def close_http_clients() -> None:
|
||||
await HttpClientFactory.close_all()
|
||||
|
||||
|
||||
def get_listenbrainz_http_client(
|
||||
settings: Optional[Settings] = None,
|
||||
timeout: Optional[float] = None,
|
||||
connect_timeout: Optional[float] = None,
|
||||
) -> httpx.AsyncClient:
|
||||
if settings is None:
|
||||
settings = get_settings()
|
||||
return HttpClientFactory.get_client(
|
||||
name="listenbrainz",
|
||||
timeout=timeout or settings.http_timeout,
|
||||
connect_timeout=connect_timeout or settings.http_connect_timeout,
|
||||
max_connections=20,
|
||||
max_keepalive=20,
|
||||
settings=settings,
|
||||
http2=False,
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TypeVar, Awaitable, Callable, Any
|
||||
from functools import wraps
|
||||
|
||||
from core.exceptions import ClientDisconnectedError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class RequestDeduplicator:
|
||||
"""
|
||||
Prevents duplicate concurrent requests by coalescing identical requests.
|
||||
|
||||
If request A is in-flight and request B arrives with the same key,
|
||||
request B will wait for A's result instead of making a duplicate call.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._pending: dict[str, asyncio.Future[Any]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def dedupe(
|
||||
self,
|
||||
key: str,
|
||||
coro_factory: Callable[[], Awaitable[T]]
|
||||
) -> T:
|
||||
while True:
|
||||
async with self._lock:
|
||||
if key in self._pending:
|
||||
logger.debug(f"Deduplicating request: {key}")
|
||||
future = self._pending[key]
|
||||
should_execute = False
|
||||
else:
|
||||
future = asyncio.get_running_loop().create_future()
|
||||
self._pending[key] = future
|
||||
should_execute = True
|
||||
|
||||
if should_execute:
|
||||
try:
|
||||
result = await coro_factory()
|
||||
future.set_result(result)
|
||||
except ClientDisconnectedError:
|
||||
future.cancel()
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
future.set_exception(e)
|
||||
finally:
|
||||
if not future.done():
|
||||
future.cancel()
|
||||
async with self._lock:
|
||||
self._pending.pop(key, None)
|
||||
|
||||
try:
|
||||
return await future
|
||||
except asyncio.CancelledError:
|
||||
continue
|
||||
|
||||
|
||||
_global_deduplicator = RequestDeduplicator()
|
||||
|
||||
|
||||
def get_deduplicator() -> RequestDeduplicator:
|
||||
return _global_deduplicator
|
||||
|
||||
|
||||
def deduplicate(key_func: Callable[..., str]):
|
||||
"""
|
||||
Decorator that deduplicates concurrent calls to the same function
|
||||
with the same key.
|
||||
|
||||
Usage:
|
||||
@deduplicate(lambda self, artist_id: f"artist:{artist_id}")
|
||||
async def get_artist(self, artist_id: str) -> Artist:
|
||||
...
|
||||
"""
|
||||
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs) -> T:
|
||||
key = key_func(*args, **kwargs)
|
||||
dedup = get_deduplicator()
|
||||
return await dedup.dedupe(key, lambda: func(*args, **kwargs))
|
||||
return wrapper
|
||||
return decorator
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from core.exceptions import ClientDisconnectedError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DisconnectCallable = Callable[[], Awaitable[bool]]
|
||||
|
||||
|
||||
async def check_disconnected(
|
||||
is_disconnected: DisconnectCallable | None,
|
||||
) -> None:
|
||||
if is_disconnected is not None and await is_disconnected():
|
||||
logger.debug("Client disconnected — aborting cover fetch")
|
||||
raise ClientDisconnectedError("Client disconnected")
|
||||
Reference in New Issue
Block a user