http_client.py python
124 lines 4.9 KB
Raw
sha256:0e9549ec7b463911bc08b7d586dc320b1ac9b1f5c943ee7e3865dcc6cb0f6f83 chore(governance): sync handover+roadmap to 84db8c8 (drift:… Human 2 days 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:6abcf1fa82a7a621ccbc945f19acdba5bc0db54569599404a1452fb4a096a199 fix(ISR): default require_independent_second_reviewer to require Human minor 2 days ago