Source code for repomatic.http

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""Shared JSON-over-HTTP fetch for the API clients.

The single implementation of the GET-and-parse-JSON loop used by the PyPI
({mod}`repomatic.pypi`), npm ({mod}`repomatic.npm`), and GitHub Releases
({mod}`repomatic.github.releases`) clients, so every datasource shares the
same timeout and truncated-body retry semantics. Caching *policy* stays with
the callers β€” each client owns its cache namespace, TTL, and serialization β€”
while {func}`get_cached_json` shares the raw-response caching mechanics for
the clients that store verbatim bodies.
"""

from __future__ import annotations

import json
import logging
from http.client import IncompleteRead
from urllib.error import URLError
from urllib.request import Request, urlopen

from .cache import get_cached_response, store_response

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Mapping
    from typing import Any


DEFAULT_TIMEOUT = 10
"""Socket timeout in seconds for every HTTP fetch repomatic makes.

Shared by the JSON clients here and the plain-text gitignore.io fetch
({mod}`repomatic.gitignore`): a stalled connection must fail the operation,
not hang it.
"""


[docs] class FetchError(RuntimeError): """Raised when a JSON fetch could not complete cleanly. Wraps every failure mode of {func}`get_json`: HTTP 4xx/5xx, network error, timeout, truncated body (after its one retry), and JSON parse error. Callers decide whether a failure is fatal (GitHub pagination, where a missing page corrupts the result) or a soft miss (PyPI/npm lookups, logged and treated as "no data"). """
[docs] def get_json( url: str, *, headers: Mapping[str, str] | None = None, timeout: float = DEFAULT_TIMEOUT, ) -> tuple[Any, bytes]: """GET *url* and parse the body as JSON, retrying once on truncation. A truncated body (`IncompleteRead`) is transient (a flaky connection or an interfering proxy), so it earns one retry; every other failure mode fails straight away. :param url: The URL to fetch. :param headers: Extra request headers, merged over the JSON `Accept` default (caller wins on conflict). :param timeout: Socket timeout in seconds. :return: `(parsed, raw_bytes)`: the decoded JSON value and the raw body (for callers that cache the verbatim response). :raises FetchError: On any failure (see the class docstring). """ request = Request(url, headers={"Accept": "application/json", **(headers or {})}) for retry in (True, False): try: with urlopen(request, timeout=timeout) as response: raw = response.read() return json.loads(raw), raw except (URLError, TimeoutError, json.JSONDecodeError, IncompleteRead) as exc: if retry and isinstance(exc, IncompleteRead): continue raise FetchError(str(exc)) from exc raise AssertionError("unreachable") # pragma: no cover
[docs] def get_json_soft(url: str, log_label: str) -> tuple[Any, bytes] | None: """GET *url* as JSON, logging any failure as a soft miss. :param url: The URL to fetch. :param log_label: Human-readable label for the debug log on failure. :return: `(parsed, raw_bytes)`, or `None` on any failure (HTTP error, network error, timeout, JSON parse error). """ try: return get_json(url) except FetchError as exc: logging.debug(f"{log_label}: {exc}") return None
[docs] def get_cached_json( namespace: str, key: str, url: str, *, ttl: int, log_label: str, force_refresh: bool = False, ) -> Any | None: """GET *url* as JSON through the raw-response cache. A fresh cached body under `namespace`/`key` short-circuits the network; otherwise the response is fetched, cached verbatim (when *ttl* is positive), and returned parsed. The caller keeps the caching policy: it picks the namespace, the cache key, and the TTL. ```{note} *force_refresh* skips the cache **read** but keeps the write, which is what separates it from `ttl=0`: the latter also skips the store, so a caller using it to bypass a stale entry would leave that entry in place for the next reader. A forced refresh replaces it. ``` :param namespace: Cache namespace (like `"pypi"` or `"npm"`). :param key: Cache key within the namespace, usually the package name. :param url: The URL to fetch on a cache miss. :param ttl: Freshness TTL in seconds; `0` disables caching. :param log_label: Human-readable label for the debug log on failure. :param force_refresh: Ignore any cached body and re-fetch, then store the fresh response. :return: The parsed JSON value, or `None` on any fetch failure. """ cached = None if force_refresh else get_cached_response(namespace, key, ttl) if cached is not None: try: return json.loads(cached) except json.JSONDecodeError: pass fetched = get_json_soft(url, log_label) if fetched is None: return None result, raw = fetched if ttl > 0: store_response(namespace, key, raw) return result