http_client.py
python
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1
docs: MuseHub-first before ISR #74 — staging solidify NEXT
Human
1 day ago
| 1 | """GET/HEAD-only upstream HTTP client with host allowlist (§HGD.6.6, §HGD.11).""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import json |
| 6 | import urllib.error |
| 7 | import urllib.request |
| 8 | from dataclasses import dataclass |
| 9 | from typing import Any, Callable |
| 10 | from urllib.parse import urlparse |
| 11 | |
| 12 | from tools.hosted_dashboard.hosts import url_host_allowed |
| 13 | |
| 14 | ALLOWED_METHODS = frozenset({"GET", "HEAD"}) |
| 15 | |
| 16 | |
| 17 | class UpstreamError(Exception): |
| 18 | """Upstream fetch failure with frozen error token.""" |
| 19 | |
| 20 | def __init__(self, token: str, *, status: int | None = None, detail: str | None = None) -> None: |
| 21 | super().__init__(token) |
| 22 | self.token = token |
| 23 | self.status = status |
| 24 | self.detail = detail |
| 25 | |
| 26 | |
| 27 | @dataclass(frozen=True) |
| 28 | class UpstreamResponse: |
| 29 | """Raw upstream response.""" |
| 30 | |
| 31 | status: int |
| 32 | headers: dict[str, str] |
| 33 | body: bytes |
| 34 | |
| 35 | |
| 36 | Transport = Callable[[str, str, dict[str, str], float], UpstreamResponse] |
| 37 | |
| 38 | |
| 39 | def _default_transport(method: str, url: str, headers: dict[str, str], timeout: float) -> UpstreamResponse: |
| 40 | if method not in ALLOWED_METHODS: |
| 41 | raise UpstreamError("method_refused", detail=f"method {method} not allowed") |
| 42 | request = urllib.request.Request(url, method=method, headers=headers) |
| 43 | try: |
| 44 | with urllib.request.urlopen(request, timeout=timeout) as response: |
| 45 | body = response.read() if method == "GET" else b"" |
| 46 | return UpstreamResponse( |
| 47 | status=int(response.status), |
| 48 | headers={k.lower(): v for k, v in response.headers.items()}, |
| 49 | body=body, |
| 50 | ) |
| 51 | except urllib.error.HTTPError as exc: |
| 52 | body = exc.read() if method == "GET" else b"" |
| 53 | return UpstreamResponse( |
| 54 | status=int(exc.code), |
| 55 | headers={k.lower(): v for k, v in exc.headers.items()} if exc.headers else {}, |
| 56 | body=body, |
| 57 | ) |
| 58 | except urllib.error.URLError as exc: |
| 59 | raise UpstreamError("upstream_unreachable", detail=str(exc.reason)) from exc |
| 60 | |
| 61 | |
| 62 | class UpstreamClient: |
| 63 | """Host-allowlisted GET/HEAD client for GitHub/MuseHub read APIs.""" |
| 64 | |
| 65 | def __init__( |
| 66 | self, |
| 67 | *, |
| 68 | token: str | None, |
| 69 | extra_allowed_hosts: frozenset[str] | None = None, |
| 70 | transport: Transport | None = None, |
| 71 | timeout: float = 15.0, |
| 72 | user_agent: str = "overseer-hosted-dashboard/0.1", |
| 73 | ) -> None: |
| 74 | self._token = token |
| 75 | self._extra = extra_allowed_hosts or frozenset() |
| 76 | self._transport = transport or _default_transport |
| 77 | self._timeout = timeout |
| 78 | self._user_agent = user_agent |
| 79 | |
| 80 | def request(self, method: str, url: str, *, accept: str = "application/json") -> UpstreamResponse: |
| 81 | method_upper = method.upper() |
| 82 | if method_upper not in ALLOWED_METHODS: |
| 83 | raise UpstreamError("method_refused", detail=f"method {method} not allowed") |
| 84 | if not url_host_allowed(url, extra_allowed=self._extra): |
| 85 | raise UpstreamError("upstream_host_refused") |
| 86 | headers = { |
| 87 | "Accept": accept, |
| 88 | "User-Agent": self._user_agent, |
| 89 | } |
| 90 | if self._token: |
| 91 | headers["Authorization"] = f"Bearer {self._token}" |
| 92 | return self._transport(method_upper, url, headers, self._timeout) |
| 93 | |
| 94 | def get_json(self, url: str) -> Any: |
| 95 | """GET JSON; map upstream status codes to frozen error tokens.""" |
| 96 | response = self.request("GET", url, accept="application/vnd.github+json") |
| 97 | return self._map_response(response, expect_json=True) |
| 98 | |
| 99 | def get_bytes(self, url: str, *, accept: str = "application/vnd.github.raw") -> bytes: |
| 100 | """GET raw bytes for Contents/raw endpoints.""" |
| 101 | response = self.request("GET", url, accept=accept) |
| 102 | return self._map_response(response, expect_json=False) # type: ignore[return-value] |
| 103 | |
| 104 | def _map_response(self, response: UpstreamResponse, *, expect_json: bool) -> Any: |
| 105 | if response.status == 404: |
| 106 | raise UpstreamError("not_found", status=404) |
| 107 | if response.status in {401, 403}: |
| 108 | # Distinguishing rate limit vs auth: GitHub sends X-RateLimit-Remaining: 0 |
| 109 | remaining = response.headers.get("x-ratelimit-remaining") |
| 110 | if remaining == "0" or response.status == 403 and "rate limit" in response.body.decode( |
| 111 | "utf-8", errors="replace" |
| 112 | ).lower(): |
| 113 | raise UpstreamError("upstream_rate_limited", status=response.status) |
| 114 | raise UpstreamError("upstream_unauthorized", status=response.status) |
| 115 | if response.status == 429: |
| 116 | raise UpstreamError("upstream_rate_limited", status=429) |
| 117 | if response.status < 200 or response.status >= 300: |
| 118 | raise UpstreamError("upstream_error", status=response.status) |
| 119 | if expect_json: |
| 120 | try: |
| 121 | return json.loads(response.body.decode("utf-8")) |
| 122 | except (UnicodeDecodeError, json.JSONDecodeError) as exc: |
| 123 | raise UpstreamError("upstream_error", detail="invalid json") from exc |
| 124 | return response.body |
File History
1 commit
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1
docs: MuseHub-first before ISR #74 — staging solidify NEXT
Human
1 day ago