-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgithub_api.py
More file actions
134 lines (119 loc) · 5.83 KB
/
Copy pathgithub_api.py
File metadata and controls
134 lines (119 loc) · 5.83 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
"""Small GitHub CLI client with bounded, status-aware retries."""
from email.utils import parsedate_to_datetime
import json
import math
import os
import subprocess
import time
MAX_ATTEMPTS = 4
REQUEST_TIMEOUT = 30
REQUEST_BUDGET = 180
TEMPORARY_STATUS = {408, 500, 502, 503, 504}
def redact(message):
for key in ("GH_TOKEN", "ISSUE_TOKEN"):
if os.environ.get(key):
message = message.replace(os.environ[key], "[REDACTED]")
return message
class GitHubError(RuntimeError):
def __init__(self, message, status=None, temporary=False, retry_after=None):
super().__init__(redact(message))
self.status = status
self.temporary = temporary
self.retry_after = retry_after
def response_parts(output):
# `gh api --include` prints a status line and response headers before JSON.
header, separator, body = output.replace("\r\n", "\n").partition("\n\n")
lines = header.splitlines()
if not separator or not lines or not lines[0].startswith("HTTP/"):
return None, {}, output
status = int(lines[0].split()[1])
headers = {}
for line in lines[1:]:
key, separator, value = line.partition(":")
if separator:
headers[key.lower()] = value.strip()
return status, headers, body
def retry_delay(status, headers, body, attempt):
limited = status == 429 or (status == 403 and (
"retry-after" in headers or headers.get("x-ratelimit-remaining") == "0"
or "rate limit" in body.lower()))
if status not in TEMPORARY_STATUS and not limited:
return None
delay = 2 ** attempt
if limited or "retry-after" in headers:
waits = []
if "retry-after" in headers:
value = headers["retry-after"]
try:
waits.append(float(value))
except ValueError:
try:
waits.append(parsedate_to_datetime(value).timestamp() - time.time())
except (TypeError, ValueError, OverflowError):
pass # An unusable hint falls back to GitHub's one-minute wait.
if headers.get("x-ratelimit-remaining") == "0":
try:
waits.append(float(headers["x-ratelimit-reset"]) - time.time() + 1)
except (KeyError, ValueError):
pass
finite_waits = [wait for wait in waits if math.isfinite(wait)]
if finite_waits:
delay = max([delay, *finite_waits])
elif limited:
delay = 60 * 2 ** (attempt - 1)
return max(1, math.ceil(delay))
def api(method, path, data=None, missing_ok=False, issue=False, retry=None):
"""Retry reads/settings, explicit safe POSTs, and rejected rate-limit requests.
Ambiguous POST failures are not replayed unless the caller opts in. In
particular, losing a comment response must not create repeated comments.
"""
env = os.environ.copy()
if issue:
env["GH_TOKEN"] = env["ISSUE_TOKEN"]
command = ["gh", "api", "--hostname", "github.com", "--include",
"--method", method, path]
if data is not None:
command += ["--input", "-"]
replay_safe = method in {"GET", "PUT", "PATCH"} if retry is None else retry
deadline = time.monotonic() + REQUEST_BUDGET
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
result = subprocess.run(command, input=json.dumps(data) if data is not None else None,
text=True, capture_output=True, env=env,
timeout=min(REQUEST_TIMEOUT, max(1, deadline - time.monotonic())))
except subprocess.TimeoutExpired as error:
stdout = error.stdout or b""
stderr = error.stderr or b""
output = stdout.decode(errors="replace") if isinstance(stdout, bytes) else stdout
detail = stderr.decode(errors="replace") if isinstance(stderr, bytes) else stderr
result = subprocess.CompletedProcess(command, 124, output, detail + "\nRequest timed out.")
status, headers, body = response_parts(result.stdout)
if result.returncode == 0 and status is not None and 200 <= status < 300:
return json.loads(body) if body.strip() else None
if missing_ok and status == 404:
return None
delay = retry_delay(status, headers, body, attempt)
# No HTTP response plus a known transport failure is retryable; a
# missing executable, bad CLI option or missing login is not.
network_error = status is None and (result.returncode == 124 or any(
text in result.stderr.lower() for text in (
"connection reset", "connection refused", "connection timed out",
"i/o timeout", "tls handshake timeout", "temporary failure",
"no such host", "unexpected eof", ": eof", "context deadline exceeded",
"error connecting to api.github.com")))
if network_error:
delay = 2 ** attempt
message = (f"{method} {path} failed (exit={result.returncode}, HTTP={status}):\n"
f"{result.stdout}\n{result.stderr}")
error = GitHubError(message, status, temporary=delay is not None, retry_after=delay)
# A rate-limit response rejected the request, so any method can retry.
can_retry = retry is not False and (replay_safe or status in {403, 429})
if delay is None or not can_retry or attempt == MAX_ATTEMPTS:
raise error
print(str(error), flush=True)
if time.monotonic() + delay + REQUEST_TIMEOUT > deadline:
raise GitHubError(f"{message}\nRequired wait {delay}s exceeds the remaining "
"request budget; no early retry was sent.", status, True, delay)
print(f"Retry {attempt}/{MAX_ATTEMPTS - 1} in {delay}s: {method} {path}", flush=True)
time.sleep(delay)
raise AssertionError("Unreachable retry state")