|
6 | 6 | import argparse |
7 | 7 | import contextlib |
8 | 8 | import datetime as dt |
| 9 | +import email.utils |
| 10 | +import errno |
9 | 11 | import hashlib |
10 | 12 | import hmac |
| 13 | +import http.client |
11 | 14 | import json |
12 | 15 | import os |
13 | 16 | import re |
|
21 | 24 | import urllib.parse |
22 | 25 | import urllib.request |
23 | 26 | import zipfile |
| 27 | +from collections.abc import Callable, Mapping |
24 | 28 | from dataclasses import dataclass |
25 | 29 | from pathlib import Path |
26 | 30 | from typing import Any |
|
40 | 44 | ALPHA_VERSION_PATTERN = re.compile(r"^2\.0\.0-alpha\.[1-9][0-9]*$") |
41 | 45 | BETA_VERSION_PATTERN = re.compile(r"^2\.0\.0-beta\.[1-9][0-9]*$") |
42 | 46 | MARKDOWN_MEDIA_TYPE = "text/markdown" |
| 47 | +GITHUB_READ_MAX_ATTEMPTS = 5 |
| 48 | +GITHUB_READ_RETRY_BASE_SECONDS = 2.0 |
| 49 | +GITHUB_READ_RETRY_MAX_SECONDS = 120.0 |
| 50 | +GITHUB_READ_REQUEST_TIMEOUT_SECONDS = 30.0 |
| 51 | +GITHUB_READ_DEADLINE_SECONDS = 600.0 |
| 52 | +INFRASTRUCTURE_EXIT_CODE = 75 |
43 | 53 |
|
44 | 54 | SOURCE_CHANGELOGS = {"workflow", "waterline", "sdk-php", "sdk-python"} |
45 | 55 | SOURCE_MANIFEST_VERSION_CONFLICT = "source-manifest-version-conflict" |
@@ -143,58 +153,272 @@ class NotFound(RecoveryError): |
143 | 153 | """A public API resource is absent.""" |
144 | 154 |
|
145 | 155 |
|
| 156 | +class PublicInfrastructureError(RuntimeError): |
| 157 | + """A bounded set of transient GitHub public-read attempts was exhausted.""" |
| 158 | + |
| 159 | + def __init__( |
| 160 | + self, |
| 161 | + endpoint_class: str, |
| 162 | + attempts: int, |
| 163 | + *, |
| 164 | + reason: str, |
| 165 | + failure: str | None = None, |
| 166 | + ) -> None: |
| 167 | + evidence = [ |
| 168 | + "classification=github-read-transient", |
| 169 | + f"endpoint_class={endpoint_class}", |
| 170 | + f"attempts={attempts}", |
| 171 | + f"reason={reason}", |
| 172 | + ] |
| 173 | + if failure is not None: |
| 174 | + evidence.append(failure) |
| 175 | + super().__init__(f"GitHub public read transient failure exhausted ({', '.join(evidence)})") |
| 176 | + |
| 177 | + |
| 178 | +class _TransientGitHubRead(RuntimeError): |
| 179 | + """One GitHub public-read attempt encountered retryable infrastructure.""" |
| 180 | + |
| 181 | + def __init__(self, evidence: str, headers: Mapping[str, str] | None = None) -> None: |
| 182 | + self.evidence = evidence |
| 183 | + self.headers = headers or {} |
| 184 | + super().__init__(evidence) |
| 185 | + |
| 186 | + |
146 | 187 | def canonical_json(value: Any) -> bytes: |
147 | 188 | return (json.dumps(value, indent=2, sort_keys=True, ensure_ascii=True) + "\n").encode() |
148 | 189 |
|
149 | 190 |
|
150 | 191 | class PublicClient: |
151 | | - def __init__(self, token: str | None = None) -> None: |
| 192 | + def __init__( |
| 193 | + self, |
| 194 | + token: str | None = None, |
| 195 | + *, |
| 196 | + max_attempts: int = GITHUB_READ_MAX_ATTEMPTS, |
| 197 | + retry_base_seconds: float = GITHUB_READ_RETRY_BASE_SECONDS, |
| 198 | + retry_max_seconds: float = GITHUB_READ_RETRY_MAX_SECONDS, |
| 199 | + request_timeout_seconds: float = GITHUB_READ_REQUEST_TIMEOUT_SECONDS, |
| 200 | + deadline_seconds: float = GITHUB_READ_DEADLINE_SECONDS, |
| 201 | + sleep: Callable[[float], None] = time.sleep, |
| 202 | + now: Callable[[], float] = time.time, |
| 203 | + monotonic: Callable[[], float] = time.monotonic, |
| 204 | + ) -> None: |
| 205 | + if ( |
| 206 | + max_attempts < 1 |
| 207 | + or retry_base_seconds < 0 |
| 208 | + or retry_max_seconds < retry_base_seconds |
| 209 | + or request_timeout_seconds <= 0 |
| 210 | + or deadline_seconds <= 0 |
| 211 | + ): |
| 212 | + raise ValueError("invalid GitHub public-read retry configuration") |
152 | 213 | self.token = token |
| 214 | + self.max_attempts = max_attempts |
| 215 | + self.retry_base_seconds = retry_base_seconds |
| 216 | + self.retry_max_seconds = retry_max_seconds |
| 217 | + self.request_timeout_seconds = request_timeout_seconds |
| 218 | + self.sleep = sleep |
| 219 | + self.now = now |
| 220 | + self.monotonic = monotonic |
| 221 | + self.deadline = monotonic() + deadline_seconds |
| 222 | + |
| 223 | + @staticmethod |
| 224 | + def _github_endpoint_class(url: str) -> str | None: |
| 225 | + parsed = urllib.parse.urlsplit(url) |
| 226 | + host = (parsed.hostname or "").lower() |
| 227 | + if host == "api.github.com": |
| 228 | + path = parsed.path |
| 229 | + endpoint_classes = ( |
| 230 | + ("/releases", "releases-api"), |
| 231 | + ("/git/", "git-api"), |
| 232 | + ("/contents/", "contents-api"), |
| 233 | + ("/commits/", "commits-api"), |
| 234 | + ("/actions/", "actions-api"), |
| 235 | + ("/environments/", "environments-api"), |
| 236 | + ) |
| 237 | + for marker, endpoint_class in endpoint_classes: |
| 238 | + if marker in path: |
| 239 | + return endpoint_class |
| 240 | + if path.startswith("/users/"): |
| 241 | + return "users-api" |
| 242 | + return "repositories-api" |
| 243 | + if host == "github.com" or host.endswith(".github.com") or host.endswith(".githubusercontent.com"): |
| 244 | + return "github-download" |
| 245 | + return None |
153 | 246 |
|
154 | | - def request( |
| 247 | + @staticmethod |
| 248 | + def _error_detail(error: urllib.error.HTTPError) -> str: |
| 249 | + try: |
| 250 | + return error.read(1024).decode(errors="replace") |
| 251 | + except OSError: |
| 252 | + return "response body unavailable" |
| 253 | + |
| 254 | + @staticmethod |
| 255 | + def _is_rate_limited(error: urllib.error.HTTPError, detail: str) -> bool: |
| 256 | + headers = error.headers or {} |
| 257 | + return error.code == 429 or ( |
| 258 | + error.code == 403 |
| 259 | + and ( |
| 260 | + headers.get("Retry-After") is not None |
| 261 | + or headers.get("X-RateLimit-Remaining") == "0" |
| 262 | + or "rate limit" in detail.lower() |
| 263 | + ) |
| 264 | + ) |
| 265 | + |
| 266 | + @staticmethod |
| 267 | + def _transport_name(error: BaseException) -> str | None: |
| 268 | + reason = error.reason if isinstance(error, urllib.error.URLError) else error |
| 269 | + if isinstance( |
| 270 | + reason, |
| 271 | + ConnectionError | TimeoutError | http.client.IncompleteRead | http.client.RemoteDisconnected, |
| 272 | + ): |
| 273 | + return type(reason).__name__ |
| 274 | + if isinstance(reason, OSError) and reason.errno in { |
| 275 | + errno.ECONNABORTED, |
| 276 | + errno.ECONNRESET, |
| 277 | + errno.EPIPE, |
| 278 | + errno.ETIMEDOUT, |
| 279 | + }: |
| 280 | + return type(reason).__name__ |
| 281 | + return None |
| 282 | + |
| 283 | + def _server_retry_delay(self, headers: Mapping[str, str]) -> float | None: |
| 284 | + delays: list[float] = [] |
| 285 | + retry_after = headers.get("Retry-After") |
| 286 | + if retry_after: |
| 287 | + try: |
| 288 | + delays.append(float(retry_after)) |
| 289 | + except ValueError: |
| 290 | + try: |
| 291 | + retry_at = email.utils.parsedate_to_datetime(retry_after) |
| 292 | + except (TypeError, ValueError): |
| 293 | + pass |
| 294 | + else: |
| 295 | + if retry_at.tzinfo is None: |
| 296 | + retry_at = retry_at.replace(tzinfo=dt.UTC) |
| 297 | + delays.append(retry_at.timestamp() - self.now()) |
| 298 | + rate_limit_reset = headers.get("X-RateLimit-Reset") |
| 299 | + if rate_limit_reset: |
| 300 | + with contextlib.suppress(ValueError): |
| 301 | + delays.append(float(rate_limit_reset) - self.now()) |
| 302 | + return max((delay for delay in delays if delay > 0), default=None) |
| 303 | + |
| 304 | + def _retry_delay(self, attempt: int, failure: _TransientGitHubRead) -> float: |
| 305 | + backoff = min(self.retry_base_seconds * (2 ** (attempt - 1)), self.retry_max_seconds) |
| 306 | + return max(backoff, self._server_retry_delay(failure.headers) or 0) |
| 307 | + |
| 308 | + def _remaining_time(self) -> float: |
| 309 | + return self.deadline - self.monotonic() |
| 310 | + |
| 311 | + def _run( |
155 | 312 | self, |
156 | 313 | url: str, |
| 314 | + operation: Callable[[urllib.response.addinfourl], Any], |
157 | 315 | *, |
158 | | - headers: dict[str, str] | None = None, |
159 | | - accept: str | None = None, |
160 | | - ) -> urllib.response.addinfourl: |
| 316 | + headers: dict[str, str] | None, |
| 317 | + accept: str | None, |
| 318 | + ) -> Any: |
| 319 | + endpoint_class = self._github_endpoint_class(url) |
| 320 | + attempt_limit = self.max_attempts if endpoint_class is not None else 1 |
161 | 321 | request_headers = {"User-Agent": "durable-workflow-release-recovery/1", **(headers or {})} |
162 | 322 | if accept: |
163 | 323 | request_headers["Accept"] = accept |
164 | 324 | if self.token and urllib.parse.urlsplit(url).hostname == "api.github.com": |
165 | 325 | request_headers["Authorization"] = f"Bearer {self.token}" |
166 | 326 | request_headers["X-GitHub-Api-Version"] = "2022-11-28" |
167 | | - request = urllib.request.Request(url, headers=request_headers) |
168 | | - try: |
169 | | - return urllib.request.urlopen(request, timeout=60) |
170 | | - except urllib.error.HTTPError as error: |
171 | | - if error.code == 404: |
172 | | - raise NotFound(f"public resource is absent: {url}") from error |
173 | | - detail = error.read(1024).decode(errors="replace") |
174 | | - raise RecoveryError(f"public request failed ({error.code}) for {url}: {detail}") from error |
175 | | - except urllib.error.URLError as error: |
176 | | - raise RecoveryError(f"public request failed for {url}: {error.reason}") from error |
177 | 327 |
|
178 | | - def json(self, url: str, *, headers: dict[str, str] | None = None, accept: str | None = None) -> Any: |
179 | | - with self.request(url, headers=headers, accept=accept) as response: |
| 328 | + for attempt in range(1, attempt_limit + 1): |
| 329 | + if endpoint_class is not None and self._remaining_time() <= 0: |
| 330 | + raise PublicInfrastructureError(endpoint_class, attempt - 1, reason="workflow-deadline") |
| 331 | + timeout = ( |
| 332 | + min(self.request_timeout_seconds, self._remaining_time()) |
| 333 | + if endpoint_class is not None |
| 334 | + else 60 |
| 335 | + ) |
| 336 | + request = urllib.request.Request(url, headers=request_headers) |
| 337 | + failure: _TransientGitHubRead | None = None |
180 | 338 | try: |
181 | | - return json.load(response) |
182 | | - except (json.JSONDecodeError, UnicodeDecodeError) as error: |
183 | | - raise RecoveryError(f"public endpoint did not return valid JSON: {url}") from error |
| 339 | + response = urllib.request.urlopen(request, timeout=timeout) |
| 340 | + result = operation(response) |
| 341 | + if endpoint_class is not None and self._remaining_time() <= 0: |
| 342 | + raise PublicInfrastructureError(endpoint_class, attempt, reason="workflow-deadline") |
| 343 | + return result |
| 344 | + except urllib.error.HTTPError as error: |
| 345 | + detail = self._error_detail(error) |
| 346 | + if endpoint_class is not None and (500 <= error.code <= 599 or self._is_rate_limited(error, detail)): |
| 347 | + failure = _TransientGitHubRead(f"status={error.code}", error.headers) |
| 348 | + elif error.code == 404: |
| 349 | + raise NotFound(f"public resource is absent: {url}") from error |
| 350 | + else: |
| 351 | + raise RecoveryError(f"public request failed ({error.code}) for {url}: {detail}") from error |
| 352 | + except (urllib.error.URLError, ConnectionError, TimeoutError, http.client.IncompleteRead) as error: |
| 353 | + transport = self._transport_name(error) |
| 354 | + if endpoint_class is not None and transport is not None: |
| 355 | + failure = _TransientGitHubRead(f"transport={transport}") |
| 356 | + else: |
| 357 | + reason = error.reason if isinstance(error, urllib.error.URLError) else error |
| 358 | + raise RecoveryError(f"public request failed for {url}: {reason}") from error |
| 359 | + |
| 360 | + assert endpoint_class is not None and failure is not None |
| 361 | + if attempt == attempt_limit: |
| 362 | + raise PublicInfrastructureError( |
| 363 | + endpoint_class, |
| 364 | + attempt, |
| 365 | + reason="retry-exhausted", |
| 366 | + failure=failure.evidence, |
| 367 | + ) |
| 368 | + delay = self._retry_delay(attempt, failure) |
| 369 | + if delay >= self._remaining_time(): |
| 370 | + raise PublicInfrastructureError( |
| 371 | + endpoint_class, |
| 372 | + attempt, |
| 373 | + reason="workflow-deadline", |
| 374 | + failure=failure.evidence, |
| 375 | + ) |
| 376 | + print( |
| 377 | + f"GitHub public read retry: endpoint_class={endpoint_class} " |
| 378 | + f"attempt={attempt}/{attempt_limit} {failure.evidence} delay={delay:g}s", |
| 379 | + file=sys.stderr, |
| 380 | + ) |
| 381 | + self.sleep(delay) |
| 382 | + raise AssertionError("GitHub public-read retry loop ended unexpectedly") |
| 383 | + |
| 384 | + def request( |
| 385 | + self, |
| 386 | + url: str, |
| 387 | + *, |
| 388 | + headers: dict[str, str] | None = None, |
| 389 | + accept: str | None = None, |
| 390 | + ) -> urllib.response.addinfourl: |
| 391 | + return self._run(url, lambda response: response, headers=headers, accept=accept) |
| 392 | + |
| 393 | + def json(self, url: str, *, headers: dict[str, str] | None = None, accept: str | None = None) -> Any: |
| 394 | + def read_json(response: urllib.response.addinfourl) -> Any: |
| 395 | + with response: |
| 396 | + try: |
| 397 | + return json.load(response) |
| 398 | + except (json.JSONDecodeError, UnicodeDecodeError) as error: |
| 399 | + raise RecoveryError(f"public endpoint did not return valid JSON: {url}") from error |
| 400 | + |
| 401 | + return self._run(url, read_json, headers=headers, accept=accept) |
184 | 402 |
|
185 | 403 | def bytes(self, url: str, *, headers: dict[str, str] | None = None, accept: str | None = None) -> bytes: |
186 | | - with self.request(url, headers=headers, accept=accept) as response: |
187 | | - return response.read() |
| 404 | + def read_bytes(response: urllib.response.addinfourl) -> bytes: |
| 405 | + with response: |
| 406 | + return response.read() |
| 407 | + |
| 408 | + return self._run(url, read_bytes, headers=headers, accept=accept) |
188 | 409 |
|
189 | 410 | def download(self, url: str, path: Path, *, expected_sha256: str | None = None) -> dict[str, Any]: |
190 | | - digest = hashlib.sha256() |
191 | | - size = 0 |
192 | | - with self.request(url) as response, path.open("wb") as destination: |
193 | | - while chunk := response.read(1024 * 1024): |
194 | | - digest.update(chunk) |
195 | | - destination.write(chunk) |
196 | | - size += len(chunk) |
197 | | - actual = digest.hexdigest() |
| 411 | + def download_once(response: urllib.response.addinfourl) -> tuple[str, int]: |
| 412 | + digest = hashlib.sha256() |
| 413 | + size = 0 |
| 414 | + with response, path.open("wb") as destination: |
| 415 | + while chunk := response.read(1024 * 1024): |
| 416 | + digest.update(chunk) |
| 417 | + destination.write(chunk) |
| 418 | + size += len(chunk) |
| 419 | + return digest.hexdigest(), size |
| 420 | + |
| 421 | + actual, size = self._run(url, download_once, headers=None, accept=None) |
198 | 422 | if expected_sha256 and actual != expected_sha256.lower(): |
199 | 423 | raise RecoveryError(f"download digest mismatch for {url}: expected {expected_sha256}, got {actual}") |
200 | 424 | return {"url": url, "size": size, "sha256": actual} |
@@ -1302,6 +1526,9 @@ def main() -> int: |
1302 | 1526 | ) |
1303 | 1527 | args.evidence.write_bytes(canonical_json(state)) |
1304 | 1528 | raise |
| 1529 | + except PublicInfrastructureError as error: |
| 1530 | + print(f"release recovery infrastructure failed: {error}", file=sys.stderr) |
| 1531 | + return INFRASTRUCTURE_EXIT_CODE |
1305 | 1532 | except RecoveryError as error: |
1306 | 1533 | print(f"release recovery error: {error}", file=sys.stderr) |
1307 | 1534 | return 1 |
|
0 commit comments