from __future__ import annotations import json from dataclasses import dataclass from typing import Any from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen @dataclass class HttpClientError(RuntimeError): status: int message: str payload: dict[str, Any] | None = None def __str__(self) -> str: return f"HTTP {self.status}: {self.message}" def _network_error_message(exc: BaseException, timeout: float) -> str: reason = getattr(exc, "reason", exc) if isinstance(exc, TimeoutError) or isinstance(reason, TimeoutError): return f"timed out after {timeout:g}s" return str(reason) or type(reason).__name__ def request_json( method: str, url: str, payload: dict[str, Any] | None = None, token: str | None = None, timeout: float = 5.0, ) -> dict[str, Any]: body = None headers = {"Accept": "application/json"} if payload is not None: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") headers["Content-Type"] = "application/json; charset=utf-8" if token: headers["Authorization"] = f"Bearer {token}" request = Request(url, data=body, headers=headers, method=method.upper()) try: with urlopen(request, timeout=timeout) as response: response_body = response.read() if not response_body: return {} return json.loads(response_body.decode("utf-8")) except HTTPError as exc: raw = exc.read().decode("utf-8", errors="replace") payload_obj = None message = raw or exc.reason try: payload_obj = json.loads(raw) message = payload_obj.get("error") or message except json.JSONDecodeError: pass raise HttpClientError(exc.code, message, payload_obj) from exc except (URLError, TimeoutError, OSError) as exc: raise HttpClientError(0, _network_error_message(exc, timeout), None) from exc