Source code for klea_utils.mcp.tool_impls.papers.http
#!/usr/bin/env python3
"""
Shared HTTP helpers for the paper search sources.
File: klea_utils/mcp/tool_impls/papers/http.py
Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""
import asyncio
import logging
import time
from typing import Any
import httpx
from klea_utils.mcp.tool_impls.session import SessionLike
from klea_utils.mcp.tool_impls.user_agent import klea_user_agent
from .errors import PaperSourceError
logger = logging.getLogger(__name__)
REQUEST_TIMEOUT = httpx.Timeout(20.0)
#: Honest client identity for the scholarly APIs (see
#: :func:`klea_utils.mcp.tool_impls.user_agent.klea_user_agent`).
_UA_PREFIX = "klea-paper-search/"
[docs]
class Throttle:
"""Keep at least ``min_interval`` seconds between requests to one service.
Used for services that ask for a gap between requests (arXiv, Semantic
Scholar and PubMed); each module explains its own gap.
"""
def __init__(self, min_interval: float):
self.min_interval = min_interval
self._lock = asyncio.Lock()
self._last = 0.0
async def __aenter__(self) -> None:
await self._lock.acquire()
wait = self._last + self.min_interval - time.monotonic()
if wait > 0:
logger.debug(f"Throttling for {wait:.2f}s")
# asyncio.sleep can wake slightly early (coarse timers on Windows).
while wait > 0:
await asyncio.sleep(wait)
wait = self._last + self.min_interval - time.monotonic()
async def __aexit__(self, *exc) -> None:
self._last = time.monotonic()
self._lock.release()
async def _get(
session: SessionLike | None,
url: str,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> Any:
"""GET *url* once and return the response.
There is no retry: a rate-limited or failing source is skipped in favour
of the next one, which is quicker than backing off on the same service.
:raises PaperSourceError: on any failure, including HTTP 429.
"""
if session is None:
raise PaperSourceError("HTTP session not initialized")
merged_headers = {"User-Agent": klea_user_agent(_UA_PREFIX)}
if headers:
merged_headers.update(headers)
try:
response = await session.get(
url,
params=params,
headers=merged_headers,
timeout=REQUEST_TIMEOUT,
follow_redirects=True,
)
except (httpx.HTTPError, TimeoutError) as exc:
raise PaperSourceError(f"Request to {url} failed: {exc}") from exc
logger.debug(f"GET {url} -> {response.status_code}")
if response.status_code == 429:
raise PaperSourceError(f"Rate limited by {url} (HTTP 429)")
if response.status_code != 200:
raise PaperSourceError(f"HTTP {response.status_code} from {url}")
return response
[docs]
async def get_json(
session: SessionLike | None,
url: str,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> Any:
"""GET *url* and return the decoded JSON body.
:raises PaperSourceError: on any failure or an unreadable body.
"""
response = await _get(session, url, params=params, headers=headers)
try:
return response.json()
except ValueError as exc:
raise PaperSourceError(f"Invalid JSON from {url}: {exc}") from exc
[docs]
async def get_text(
session: SessionLike | None,
url: str,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> str:
"""GET *url* and return the body as text (for the XML APIs).
:raises PaperSourceError: on any failure.
"""
response = await _get(session, url, params=params, headers=headers)
return response.text