Viewing files at commit 62758990fe31Commit detailsView latest

Browse files
scripts/oakbench/platform_api.py 256 lines · 9.5 KB · python
1
"""Stdlib GitHub REST client for the platform lane's harness-api driver.
2
3
Every call goes through one ``_request`` path that updates ``api_metrics``
4
with exact, observed values: request/response byte counts, the last-seen
5
``X-RateLimit-Remaining`` header, and GitHub request ids (capped at 50).
6
Token fields for this driver stay null in rows — API JSON is not an agent
7
transcript, so token costs would be fiction (ADR-0002: null means
8
unmeasured, never invented).
9
10
The transport is injectable: a callable taking a request dict
11
(``{"method", "url", "headers", "body"}``, body bytes or None) and returning
12
a response dict (``{"status", "headers", "body"}``, body bytes). Tests use
13
fake transports; the default transport uses urllib and never retries.
14
"""
15
16
from __future__ import annotations
17
18
import base64
19
import json
20
import time
21
import urllib.error
22
import urllib.request
23
from typing import Any, Callable, Optional
24
25
DEFAULT_BASE_URL = "https://api.github.com"
26
USER_AGENT = "oakbench-platform-lane"
27
API_VERSION = "2022-11-28"
28
REQUEST_ID_CAP = 50
29
DEFAULT_TIMEOUT_S = 30.0
30
31
Transport = Callable[[dict[str, Any]], dict[str, Any]]
32
33
34
class GitHubApiError(Exception):
35
    """An API response with status >= 400; carries the observed status."""
36
37
    def __init__(self, status: int, method: str, url: str, body_text: str) -> None:
38
        self.status = status
39
        self.method = method
40
        self.url = url
41
        self.body_text = body_text
42
        super().__init__(f"{method} {url} -> {status}: {body_text[:200]}")
43
44
45
def _urllib_transport(request: dict[str, Any]) -> dict[str, Any]:
46
    req = urllib.request.Request(
47
        request["url"],
48
        data=request["body"],
49
        method=request["method"],
50
        headers=request["headers"],
51
    )
52
    try:
53
        with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT_S) as response:
54
            return {
55
                "status": response.status,
56
                "headers": dict(response.headers.items()),
57
                "body": response.read(),
58
            }
59
    except urllib.error.HTTPError as exc:
60
        return {
61
            "status": exc.code,
62
            "headers": dict(exc.headers.items()) if exc.headers else {},
63
            "body": exc.read(),
64
        }
65
66
67
def _header(headers: dict[str, Any], name: str) -> Optional[str]:
68
    wanted = name.lower()
69
    for key, value in headers.items():
70
        if str(key).lower() == wanted:
71
            return str(value)
72
    return None
73
74
75
def new_api_metrics() -> dict[str, Any]:
76
    return {
77
        "request_count": 0,
78
        "request_bytes": 0,
79
        "response_bytes": 0,
80
        "rate_limit_remaining": None,
81
        "request_ids": [],
82
    }
83
84
85
class GitHubClient:
86
    """Minimal GitHub REST client with exact per-call accounting."""
87
88
    def __init__(
89
        self,
90
        base_url: str = DEFAULT_BASE_URL,
91
        token: Optional[str] = None,
92
        transport: Optional[Transport] = None,
93
    ) -> None:
94
        self.base_url = base_url.rstrip("/")
95
        self.token = token
96
        self.transport = transport or _urllib_transport
97
        self.api_metrics = new_api_metrics()
98
99
    # -- request plumbing ---------------------------------------------------
100
101
    def _headers(self, has_body: bool) -> dict[str, str]:
102
        headers = {
103
            "Accept": "application/vnd.github+json",
104
            "User-Agent": USER_AGENT,
105
            "X-GitHub-Api-Version": API_VERSION,
106
        }
107
        if self.token:
108
            headers["Authorization"] = f"Bearer {self.token}"
109
        if has_body:
110
            headers["Content-Type"] = "application/json"
111
        return headers
112
113
    def _request(self, method: str, path: str, payload: Optional[dict[str, Any]] = None) -> dict[str, Any]:
114
        url = self.base_url + path
115
        body = None if payload is None else json.dumps(payload, sort_keys=True).encode("utf-8")
116
        headers = self._headers(body is not None)
117
        response = self.transport({"method": method, "url": url, "headers": headers, "body": body})
118
119
        metrics = self.api_metrics
120
        metrics["request_count"] += 1
121
        header_bytes = sum(len(str(k)) + len(str(v)) for k, v in headers.items())
122
        metrics["request_bytes"] += len(method) + len(url) + header_bytes + len(body or b"")
123
        response_body = response.get("body") or b""
124
        metrics["response_bytes"] += len(response_body)
125
        response_headers = response.get("headers") or {}
126
        remaining = _header(response_headers, "X-RateLimit-Remaining")
127
        if remaining is not None:
128
            try:
129
                metrics["rate_limit_remaining"] = int(remaining)
130
            except ValueError:
131
                pass
132
        request_id = _header(response_headers, "X-GitHub-Request-Id")
133
        if request_id is not None:
134
            metrics["request_ids"].append(request_id)
135
            if len(metrics["request_ids"]) > REQUEST_ID_CAP:
136
                # Keep the most recent ids: late requests are the ones a
137
                # support escalation about a stuck merge actually needs.
138
                del metrics["request_ids"][: len(metrics["request_ids"]) - REQUEST_ID_CAP]
139
140
        status = int(response["status"])
141
        if status >= 400:
142
            raise GitHubApiError(status, method, url, response_body.decode("utf-8", "replace"))
143
        if not response_body:
144
            return {}
145
        return json.loads(response_body.decode("utf-8"))
146
147
    # -- pr_single_anatomy primitives ---------------------------------------
148
149
    def get_repo(self, owner_repo: str) -> dict[str, Any]:
150
        return self._request("GET", f"/repos/{owner_repo}")
151
152
    def get_main_sha(self, owner_repo: str, branch: str = "main") -> str:
153
        data = self._request("GET", f"/repos/{owner_repo}/git/ref/heads/{branch}")
154
        return str(data["object"]["sha"])
155
156
    def create_ref(self, owner_repo: str, ref: str, sha: str) -> dict[str, Any]:
157
        full_ref = ref if ref.startswith("refs/") else f"refs/heads/{ref}"
158
        return self._request("POST", f"/repos/{owner_repo}/git/refs", {"ref": full_ref, "sha": sha})
159
160
    def put_file(
161
        self,
162
        owner_repo: str,
163
        path: str,
164
        message: str,
165
        content: bytes,
166
        branch: str,
167
        sha: Optional[str] = None,
168
    ) -> dict[str, Any]:
169
        payload: dict[str, Any] = {
170
            "message": message,
171
            "content": base64.b64encode(content).decode("ascii"),
172
            "branch": branch,
173
        }
174
        if sha is not None:
175
            payload["sha"] = sha
176
        return self._request("PUT", f"/repos/{owner_repo}/contents/{path}", payload)
177
178
    def create_pr(
179
        self,
180
        owner_repo: str,
181
        title: str,
182
        head: str,
183
        base: str = "main",
184
        body: str = "",
185
    ) -> dict[str, Any]:
186
        return self._request(
187
            "POST",
188
            f"/repos/{owner_repo}/pulls",
189
            {"title": title, "head": head, "base": base, "body": body},
190
        )
191
192
    def get_pr(self, owner_repo: str, number: int) -> dict[str, Any]:
193
        return self._request("GET", f"/repos/{owner_repo}/pulls/{number}")
194
195
    def merge_pr(self, owner_repo: str, number: int, merge_method: str = "merge") -> dict[str, Any]:
196
        return self._request(
197
            "PUT", f"/repos/{owner_repo}/pulls/{number}/merge", {"merge_method": merge_method}
198
        )
199
200
    # -- check-instant protection variant primitives -------------------------
201
    # The harness itself posts the required status (deterministic, free, no
202
    # Actions); the race runner then settles on the combined status going
203
    # green. Accounting is identical to every other method: one _request.
204
205
    def post_status(
206
        self,
207
        owner_repo: str,
208
        sha: str,
209
        state: str,
210
        context: str,
211
        description: str = "",
212
        target_url: str = "",
213
    ) -> dict[str, Any]:
214
        payload: dict[str, Any] = {"state": state, "context": context}
215
        if description:
216
            payload["description"] = description
217
        if target_url:
218
            payload["target_url"] = target_url
219
        return self._request("POST", f"/repos/{owner_repo}/statuses/{sha}", payload)
220
221
    def get_combined_status(self, owner_repo: str, sha: str) -> dict[str, Any]:
222
        return self._request("GET", f"/repos/{owner_repo}/commits/{sha}/status")
223
224
    def delete_ref(self, owner_repo: str, ref: str) -> dict[str, Any]:
225
        short = ref[len("refs/"):] if ref.startswith("refs/") else f"heads/{ref}" if not ref.startswith("heads/") else ref
226
        return self._request("DELETE", f"/repos/{owner_repo}/git/refs/{short}")
227
228
229
class RateLimitBudgeter:
230
    """Pure pacing math to stay under an ops-per-hour budget.
231
232
    ``pace()`` returns the wait (seconds) the caller must inject before its
233
    next request. The budgeter records injected waits and never subtracts
234
    them from anything — the caller adds the value to its pacing accounting
235
    (race.pacing.injected) so paced waits stay visible in the data instead of
236
    silently inflating operation durations.
237
    """
238
239
    def __init__(self, ops_per_hour: int, clock: Optional[Callable[[], float]] = None) -> None:
240
        if ops_per_hour <= 0:
241
            raise ValueError(f"ops_per_hour must be positive, got {ops_per_hour!r}")
242
        self.ops_per_hour = ops_per_hour
243
        self.min_spacing_s = 3600.0 / float(ops_per_hour)
244
        self.clock = clock or time.monotonic
245
        self.injected_wait_s_total = 0.0
246
        self._next_slot: Optional[float] = None
247
248
    def pace(self) -> float:
249
        now = self.clock()
250
        if self._next_slot is None:
251
            self._next_slot = now + self.min_spacing_s
252
            return 0.0
253
        wait = max(0.0, self._next_slot - now)
254
        self._next_slot = max(now, self._next_slot) + self.min_spacing_s
255
        self.injected_wait_s_total += wait
256
        return wait