from __future__ import annotations import json import socket import time from dataclasses import dataclass from http.client import IncompleteRead, RemoteDisconnected from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import urlencode, urljoin from urllib.request import OpenerDirector, Request, build_opener @dataclass class HttpJsonError(RuntimeError): message: str status_code: int | None = None payload: Any = None def __str__(self) -> str: if self.status_code is None: return self.message return f"{self.message} (status={self.status_code})" class JsonHttpClient: def __init__( self, base_url: str = "", default_headers: dict[str, str] | None = None, timeout: int = 30, retries: int = 2, backoff_seconds: float = 1.0, opener: OpenerDirector | None = None, ) -> None: self.base_url = base_url.rstrip("/") self.default_headers = default_headers or {} self.timeout = timeout self.retries = retries self.backoff_seconds = backoff_seconds self.opener = opener or build_opener() def request_json( self, method: str, path: str, *, query: dict[str, Any] | None = None, data: dict[str, Any] | None = None, headers: dict[str, str] | None = None, retryable_statuses: set[int] | None = None, ) -> Any: retryable_statuses = retryable_statuses or {429, 500, 502, 503, 504} url = self._build_url(path, query) merged_headers = { "Accept": "application/json", **self.default_headers, **(headers or {}), } body = None if data is not None: body = json.dumps(data).encode("utf-8") merged_headers.setdefault("Content-Type", "application/json") for attempt in range(self.retries + 1): request = Request(url=url, data=body, headers=merged_headers, method=method.upper()) try: with self.opener.open(request, timeout=self.timeout) as response: raw = response.read().decode("utf-8") if not raw: return None try: return json.loads(raw) except json.JSONDecodeError as exc: raise HttpJsonError(f"Invalid JSON response from {url}", payload=raw) from exc except HTTPError as exc: payload = self._decode_error_payload(exc) if exc.code in retryable_statuses and attempt < self.retries: time.sleep(self.backoff_seconds * (attempt + 1)) continue raise HttpJsonError(f"HTTP request failed for {url}", status_code=exc.code, payload=payload) from exc except (URLError, TimeoutError, socket.timeout, RemoteDisconnected, IncompleteRead) as exc: if attempt < self.retries: time.sleep(self.backoff_seconds * (attempt + 1)) continue raise HttpJsonError(f"Network request failed for {url}") from exc raise HttpJsonError(f"Request exhausted retries for {url}") def _build_url(self, path: str, query: dict[str, Any] | None) -> str: if path.startswith("http://") or path.startswith("https://"): url = path else: base = self.base_url + "/" url = urljoin(base, path.lstrip("/")) if query: pairs = [(key, value) for key, value in query.items() if value is not None] encoded = urlencode(pairs) if encoded: separator = "&" if "?" in url else "?" url = f"{url}{separator}{encoded}" return url @staticmethod def _decode_error_payload(exc: HTTPError) -> Any: try: raw = exc.read().decode("utf-8") except Exception: return None if not raw: return None try: return json.loads(raw) except json.JSONDecodeError: return raw