Skip to content

Commit 1ad2bc7

Browse files
Keep GitHub authentication failures outside transient retry handling
1 parent b7a6ddd commit 1ad2bc7

2 files changed

Lines changed: 387 additions & 30 deletions

File tree

scripts/ci/component-release-recovery.py

Lines changed: 257 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66
import argparse
77
import contextlib
88
import datetime as dt
9+
import email.utils
10+
import errno
911
import hashlib
1012
import hmac
13+
import http.client
1114
import json
1215
import os
1316
import re
@@ -21,6 +24,7 @@
2124
import urllib.parse
2225
import urllib.request
2326
import zipfile
27+
from collections.abc import Callable, Mapping
2428
from dataclasses import dataclass
2529
from pathlib import Path
2630
from typing import Any
@@ -40,6 +44,12 @@
4044
ALPHA_VERSION_PATTERN = re.compile(r"^2\.0\.0-alpha\.[1-9][0-9]*$")
4145
BETA_VERSION_PATTERN = re.compile(r"^2\.0\.0-beta\.[1-9][0-9]*$")
4246
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
4353

4454
SOURCE_CHANGELOGS = {"workflow", "waterline", "sdk-php", "sdk-python"}
4555
SOURCE_MANIFEST_VERSION_CONFLICT = "source-manifest-version-conflict"
@@ -143,58 +153,272 @@ class NotFound(RecoveryError):
143153
"""A public API resource is absent."""
144154

145155

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+
146187
def canonical_json(value: Any) -> bytes:
147188
return (json.dumps(value, indent=2, sort_keys=True, ensure_ascii=True) + "\n").encode()
148189

149190

150191
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")
152213
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
153246

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(
155312
self,
156313
url: str,
314+
operation: Callable[[urllib.response.addinfourl], Any],
157315
*,
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
161321
request_headers = {"User-Agent": "durable-workflow-release-recovery/1", **(headers or {})}
162322
if accept:
163323
request_headers["Accept"] = accept
164324
if self.token and urllib.parse.urlsplit(url).hostname == "api.github.com":
165325
request_headers["Authorization"] = f"Bearer {self.token}"
166326
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
177327

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
180338
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)
184402

185403
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)
188409

189410
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)
198422
if expected_sha256 and actual != expected_sha256.lower():
199423
raise RecoveryError(f"download digest mismatch for {url}: expected {expected_sha256}, got {actual}")
200424
return {"url": url, "size": size, "sha256": actual}
@@ -1302,6 +1526,9 @@ def main() -> int:
13021526
)
13031527
args.evidence.write_bytes(canonical_json(state))
13041528
raise
1529+
except PublicInfrastructureError as error:
1530+
print(f"release recovery infrastructure failed: {error}", file=sys.stderr)
1531+
return INFRASTRUCTURE_EXIT_CODE
13051532
except RecoveryError as error:
13061533
print(f"release recovery error: {error}", file=sys.stderr)
13071534
return 1

0 commit comments

Comments
 (0)