asyncgh 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of asyncgh might be problematic. Click here for more details.
- asyncgh/__init__.py +42 -0
- asyncgh/client.py +242 -0
- asyncgh/endpoints.py +62 -0
- asyncgh/py.typed +0 -0
- asyncgh-0.1.0.dist-info/METADATA +84 -0
- asyncgh-0.1.0.dist-info/RECORD +8 -0
- asyncgh-0.1.0.dist-info/WHEEL +4 -0
- asyncgh-0.1.0.dist-info/licenses/LICENSE +21 -0
asyncgh/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""asyncgh -- the GitHub REST API transport shared by repo-admin's scripts.
|
|
2
|
+
|
|
3
|
+
Auth via the token `gh` already holds, one shared async client, raw and
|
|
4
|
+
raise-on-error request helpers, `Link`-header pagination, and thin wrappers
|
|
5
|
+
over the few endpoints more than one script needs (account-wide repo
|
|
6
|
+
listing, Actions secrets). No config-file loading, no repo filtering -- those
|
|
7
|
+
stay with the caller.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .client import (
|
|
13
|
+
API_BASE,
|
|
14
|
+
GhError,
|
|
15
|
+
aclose_client,
|
|
16
|
+
api_json,
|
|
17
|
+
api_request,
|
|
18
|
+
error_message,
|
|
19
|
+
graphql,
|
|
20
|
+
paginated,
|
|
21
|
+
)
|
|
22
|
+
from .endpoints import (
|
|
23
|
+
encrypt_secret_value,
|
|
24
|
+
fetch_repos_json,
|
|
25
|
+
public_repos_json,
|
|
26
|
+
set_repo_secret,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"API_BASE",
|
|
31
|
+
"GhError",
|
|
32
|
+
"aclose_client",
|
|
33
|
+
"api_json",
|
|
34
|
+
"api_request",
|
|
35
|
+
"encrypt_secret_value",
|
|
36
|
+
"error_message",
|
|
37
|
+
"fetch_repos_json",
|
|
38
|
+
"graphql",
|
|
39
|
+
"paginated",
|
|
40
|
+
"public_repos_json",
|
|
41
|
+
"set_repo_secret",
|
|
42
|
+
]
|
asyncgh/client.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""GitHub REST API transport: auth, the shared async client, and the
|
|
2
|
+
request / pagination helpers every endpoint wrapper is built on.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx2
|
|
13
|
+
import stamina
|
|
14
|
+
|
|
15
|
+
API_BASE = "https://api.github.com"
|
|
16
|
+
|
|
17
|
+
# Retry transport failures and transient statuses (429 + 5xx). `attempts` is
|
|
18
|
+
# total tries, so MAX_RETRIES=3 means one call plus three retries. There is no
|
|
19
|
+
# wall-clock timeout: GitHub's Retry-After on a secondary rate limit is often
|
|
20
|
+
# a minute or more, and honouring it is the whole point.
|
|
21
|
+
MAX_RETRIES = int(os.environ.get("GH_MAX_RETRIES", "3"))
|
|
22
|
+
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
23
|
+
RETRY_WAIT_INITIAL = 1.0
|
|
24
|
+
RETRY_WAIT_MAX = 60.0
|
|
25
|
+
RETRY_WAIT_JITTER = 1.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GhError(RuntimeError):
|
|
29
|
+
"""A GitHub API call -- or a caller's worker function -- failed
|
|
30
|
+
unexpectedly.
|
|
31
|
+
|
|
32
|
+
status_code is set for HTTP errors raised by api_json(), so callers can
|
|
33
|
+
branch on the real status code (e.g. 403 vs 404) instead of
|
|
34
|
+
string-matching an error message. Plain RuntimeError rather than a
|
|
35
|
+
reconcilekit.ReconcileError subclass -- asyncgh has no dependency on
|
|
36
|
+
reconcilekit, so it stays installable standalone; reconcilekit's own
|
|
37
|
+
run_parallel(..., error_cls=) only requires type[Exception], so GhError
|
|
38
|
+
still works there unchanged.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, message: str, *, status_code: int | None = None):
|
|
42
|
+
super().__init__(message)
|
|
43
|
+
self.status_code = status_code
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _auth_token() -> str:
|
|
47
|
+
"""Reads the token `gh` already has -- keychain storage, SSO, and 2FA are
|
|
48
|
+
already solved by `gh auth login`, so this reuses that instead of
|
|
49
|
+
managing a separate credential.
|
|
50
|
+
"""
|
|
51
|
+
result = subprocess.run(
|
|
52
|
+
["gh", "auth", "token"], capture_output=True, text=True, check=False
|
|
53
|
+
)
|
|
54
|
+
if result.returncode != 0:
|
|
55
|
+
raise GhError(f"gh auth token failed: {result.stderr.strip()}")
|
|
56
|
+
return result.stdout.strip()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_client: httpx2.AsyncClient | None = None
|
|
60
|
+
_client_lock = asyncio.Lock()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _get_client() -> httpx2.AsyncClient:
|
|
64
|
+
# One shared AsyncClient rather than one per thread: there's no thread
|
|
65
|
+
# pool, and gh auth token only needs to be paid once per process.
|
|
66
|
+
global _client
|
|
67
|
+
if _client is None:
|
|
68
|
+
async with _client_lock:
|
|
69
|
+
if _client is None:
|
|
70
|
+
_client = httpx2.AsyncClient(
|
|
71
|
+
headers={
|
|
72
|
+
"Authorization": f"Bearer {_auth_token()}",
|
|
73
|
+
"Accept": "application/vnd.github+json",
|
|
74
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
75
|
+
},
|
|
76
|
+
timeout=30,
|
|
77
|
+
)
|
|
78
|
+
return _client
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def aclose_client() -> None:
|
|
82
|
+
global _client
|
|
83
|
+
if _client is not None:
|
|
84
|
+
await _client.aclose()
|
|
85
|
+
_client = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _should_retry(exc: Exception) -> bool | float:
|
|
89
|
+
"""stamina backoff hook. A `Retry-After` header (GitHub sends one on
|
|
90
|
+
secondary rate limits) sets the exact wait; otherwise transport errors
|
|
91
|
+
and RETRY_STATUSES responses retry on stamina's default backoff, and
|
|
92
|
+
everything else propagates.
|
|
93
|
+
"""
|
|
94
|
+
response = getattr(exc, "response", None)
|
|
95
|
+
if response is not None:
|
|
96
|
+
header = response.headers.get("retry-after")
|
|
97
|
+
if header:
|
|
98
|
+
try:
|
|
99
|
+
return float(header)
|
|
100
|
+
except ValueError:
|
|
101
|
+
pass
|
|
102
|
+
return response.status_code in RETRY_STATUSES
|
|
103
|
+
return isinstance(exc, httpx2.TransportError)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def api_request(
|
|
107
|
+
method: str, path: str, *, json: Any = None, params: dict | None = None
|
|
108
|
+
) -> httpx2.Response:
|
|
109
|
+
"""Makes one GitHub REST API call and returns the raw Response --
|
|
110
|
+
callers decide what a given status means for their endpoint (e.g. a 404
|
|
111
|
+
means "feature disabled" for vulnerability-alerts but "not found"
|
|
112
|
+
everywhere else). Raises GhError only for genuine transport failures
|
|
113
|
+
(DNS, timeout, connection reset); HTTP error statuses are returned, not
|
|
114
|
+
raised.
|
|
115
|
+
|
|
116
|
+
Transport errors and transient statuses (429, 5xx) are retried up to
|
|
117
|
+
MAX_RETRIES times, honouring `Retry-After`. GitHub's writes here are
|
|
118
|
+
idempotent (secret PUTs replace), so retrying any method is safe.
|
|
119
|
+
"""
|
|
120
|
+
url = path if path.startswith("http") else f"{API_BASE}{path}"
|
|
121
|
+
http = await _get_client()
|
|
122
|
+
try:
|
|
123
|
+
async for attempt in stamina.retry_context(
|
|
124
|
+
on=_should_retry,
|
|
125
|
+
attempts=MAX_RETRIES + 1,
|
|
126
|
+
timeout=None,
|
|
127
|
+
wait_initial=RETRY_WAIT_INITIAL,
|
|
128
|
+
wait_max=RETRY_WAIT_MAX,
|
|
129
|
+
wait_jitter=RETRY_WAIT_JITTER,
|
|
130
|
+
):
|
|
131
|
+
with attempt:
|
|
132
|
+
try:
|
|
133
|
+
response = await http.request(method, url, json=json, params=params)
|
|
134
|
+
except httpx2.TransportError:
|
|
135
|
+
raise
|
|
136
|
+
except httpx2.HTTPError as exc:
|
|
137
|
+
raise GhError(str(exc)) from exc
|
|
138
|
+
if response.status_code in RETRY_STATUSES:
|
|
139
|
+
response.raise_for_status()
|
|
140
|
+
return response
|
|
141
|
+
except httpx2.HTTPStatusError as exc:
|
|
142
|
+
return exc.response # retryable status, retries spent -- let the caller judge
|
|
143
|
+
except httpx2.TransportError as exc:
|
|
144
|
+
raise GhError(str(exc)) from exc
|
|
145
|
+
raise GhError("api_request retry loop exited without a response") # unreachable
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def error_message(response: httpx2.Response) -> str:
|
|
149
|
+
"""Extracts GitHub's own `message` field from an error response body,
|
|
150
|
+
falling back to the raw response text if the body isn't JSON.
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
return response.json().get("message", response.text)
|
|
154
|
+
except ValueError:
|
|
155
|
+
return response.text
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def api_json(
|
|
159
|
+
method: str, path: str, *, json: Any = None, params: dict | None = None
|
|
160
|
+
) -> dict:
|
|
161
|
+
"""Like api_request, but raises GhError (with status_code and GitHub's
|
|
162
|
+
own error message) on any non-2xx response, and returns the parsed JSON
|
|
163
|
+
body -- or {} for a body-less response like 204 No Content -- on success.
|
|
164
|
+
"""
|
|
165
|
+
response = await api_request(method, path, json=json, params=params)
|
|
166
|
+
if not response.is_success:
|
|
167
|
+
raise GhError(error_message(response), status_code=response.status_code)
|
|
168
|
+
return response.json() if response.content else {}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class _GraphQLRateLimited(Exception):
|
|
172
|
+
"""Internal marker: GraphQL answered 200 with errors[].type ==
|
|
173
|
+
RATE_LIMITED, which api_request's transport/5xx retry never sees since
|
|
174
|
+
it only looks at the HTTP status. Retried at this layer instead.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def graphql(query: str, variables: dict | None = None) -> dict:
|
|
179
|
+
"""Runs one GraphQL query and returns its `data` object.
|
|
180
|
+
|
|
181
|
+
GraphQL always answers HTTP 200, even for query errors, so success lives
|
|
182
|
+
in the body: `errors` present (with `data` null or partial) raises
|
|
183
|
+
GhError with the joined messages -- callers have no partial-data story,
|
|
184
|
+
so partial loss is worse than a loud failure. RATE_LIMITED errors retry
|
|
185
|
+
(stamina, same backoff as api_request) since they're the one transient
|
|
186
|
+
GraphQL failure mode; other GraphQL errors (bad query, not found) are
|
|
187
|
+
not transient and raise immediately.
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
async for attempt in stamina.retry_context(
|
|
191
|
+
on=lambda exc: isinstance(exc, _GraphQLRateLimited),
|
|
192
|
+
attempts=MAX_RETRIES + 1,
|
|
193
|
+
timeout=None,
|
|
194
|
+
wait_initial=RETRY_WAIT_INITIAL,
|
|
195
|
+
wait_max=RETRY_WAIT_MAX,
|
|
196
|
+
wait_jitter=RETRY_WAIT_JITTER,
|
|
197
|
+
):
|
|
198
|
+
with attempt:
|
|
199
|
+
response = await api_request(
|
|
200
|
+
"POST",
|
|
201
|
+
"/graphql",
|
|
202
|
+
json={"query": query, "variables": variables or {}},
|
|
203
|
+
)
|
|
204
|
+
if not response.is_success:
|
|
205
|
+
raise GhError(
|
|
206
|
+
error_message(response), status_code=response.status_code
|
|
207
|
+
)
|
|
208
|
+
body = response.json()
|
|
209
|
+
errors = body.get("errors")
|
|
210
|
+
if errors:
|
|
211
|
+
if any(error.get("type") == "RATE_LIMITED" for error in errors):
|
|
212
|
+
raise _GraphQLRateLimited(
|
|
213
|
+
"; ".join(
|
|
214
|
+
error.get("message", str(error)) for error in errors
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
raise GhError(
|
|
218
|
+
"; ".join(error.get("message", str(error)) for error in errors)
|
|
219
|
+
)
|
|
220
|
+
return body["data"]
|
|
221
|
+
except _GraphQLRateLimited as exc:
|
|
222
|
+
raise GhError(str(exc)) from exc
|
|
223
|
+
raise GhError("graphql retry loop exited without a response") # unreachable
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def paginated(
|
|
227
|
+
method: str, path: str, *, params: dict | None = None
|
|
228
|
+
) -> list[dict]:
|
|
229
|
+
"""Follows GitHub's `Link: rel="next"` header, concatenating every page's
|
|
230
|
+
JSON array into one list.
|
|
231
|
+
"""
|
|
232
|
+
items = []
|
|
233
|
+
url = path
|
|
234
|
+
query = params
|
|
235
|
+
while url:
|
|
236
|
+
response = await api_request(method, url, params=query)
|
|
237
|
+
if not response.is_success:
|
|
238
|
+
raise GhError(error_message(response), status_code=response.status_code)
|
|
239
|
+
items.extend(response.json())
|
|
240
|
+
url = response.links.get("next", {}).get("url")
|
|
241
|
+
query = None # the "next" link already carries the full query string
|
|
242
|
+
return items
|
asyncgh/endpoints.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Thin wrappers over the handful of GitHub REST endpoints shared across
|
|
2
|
+
repo-admin's scripts: account-wide repo listing and Actions secrets.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
|
|
9
|
+
from nacl import encoding, public
|
|
10
|
+
|
|
11
|
+
from .client import api_json, paginated
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def fetch_repos_json(owner: str) -> list[dict]:
|
|
15
|
+
"""Lists every repo for `owner`. When `owner` is the authenticated `gh`
|
|
16
|
+
user, uses /user/repos so private repos are included; otherwise falls
|
|
17
|
+
back to /users/{owner}/repos, which only ever returns public repos.
|
|
18
|
+
"""
|
|
19
|
+
viewer = (await api_json("GET", "/user")).get("login")
|
|
20
|
+
if owner == viewer:
|
|
21
|
+
return await paginated(
|
|
22
|
+
"GET", "/user/repos", params={"affiliation": "owner", "per_page": "100"}
|
|
23
|
+
)
|
|
24
|
+
return await paginated("GET", f"/users/{owner}/repos", params={"per_page": "100"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def public_repos_json(owner: str) -> list[dict]:
|
|
28
|
+
"""Lists `owner`'s public repos only, via /users/{owner}/repos -- unlike
|
|
29
|
+
fetch_repos_json, this always excludes private repos even when `owner`
|
|
30
|
+
is the authenticated user, so no /user call is needed to branch on it.
|
|
31
|
+
"""
|
|
32
|
+
return await paginated("GET", f"/users/{owner}/repos", params={"per_page": "100"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def encrypt_secret_value(public_key_b64: str, value: str) -> str:
|
|
36
|
+
"""Encrypts `value` for GitHub's Actions secrets API using a repo's
|
|
37
|
+
public key, per GitHub's documented libsodium sealed-box scheme.
|
|
38
|
+
"""
|
|
39
|
+
public_key = public.PublicKey(
|
|
40
|
+
public_key_b64.encode("utf-8"), encoding.Base64Encoder
|
|
41
|
+
)
|
|
42
|
+
encrypted = public.SealedBox(public_key).encrypt(value.encode("utf-8"))
|
|
43
|
+
return base64.b64encode(encrypted).decode("utf-8")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def set_repo_secret(
|
|
47
|
+
owner: str, repo_name: str, secret_name: str, value: str
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Sets one repo's Actions secret via GitHub's REST API: fetches the
|
|
50
|
+
repo's current public key, encrypts `value` for it, and PUTs the
|
|
51
|
+
result. The plaintext value never leaves this process -- it's encrypted
|
|
52
|
+
in memory before the request body is built.
|
|
53
|
+
"""
|
|
54
|
+
key_data = await api_json(
|
|
55
|
+
"GET", f"/repos/{owner}/{repo_name}/actions/secrets/public-key"
|
|
56
|
+
)
|
|
57
|
+
encrypted_value = encrypt_secret_value(key_data["key"], value)
|
|
58
|
+
await api_json(
|
|
59
|
+
"PUT",
|
|
60
|
+
f"/repos/{owner}/{repo_name}/actions/secrets/{secret_name}",
|
|
61
|
+
json={"encrypted_value": encrypted_value, "key_id": key_data["key_id"]},
|
|
62
|
+
)
|
asyncgh/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: asyncgh
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Minimal async GitHub REST + GraphQL transport: auth, retry, pagination -- no generated models
|
|
5
|
+
Project-URL: Homepage, https://github.com/hugoh/gh-workflows/tree/main/asyncgh
|
|
6
|
+
Project-URL: Issues, https://github.com/hugoh/gh-workflows/issues
|
|
7
|
+
Project-URL: Repository, https://github.com/hugoh/gh-workflows
|
|
8
|
+
Author: Hugo Haas
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: httpx2>=2.10
|
|
22
|
+
Requires-Dist: pynacl>=1.5
|
|
23
|
+
Requires-Dist: stamina>=25.2
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# asyncgh
|
|
27
|
+
|
|
28
|
+
A minimal async GitHub API transport — the plumbing an account-wide config
|
|
29
|
+
tool needs before any of its domain logic: authentication, a shared
|
|
30
|
+
connection, retrying error handling, REST pagination, and GraphQL.
|
|
31
|
+
|
|
32
|
+
There is no client object to construct and no config. It reads the token
|
|
33
|
+
`gh auth login` already stored, keeps one process-wide `httpx2.AsyncClient`,
|
|
34
|
+
and exposes plain functions.
|
|
35
|
+
|
|
36
|
+
## Why not PyGithub / githubkit / gidgethub / fast.ai's `ghapi`?
|
|
37
|
+
|
|
38
|
+
| Library | Async | Shape | Trade-off vs `asyncgh` |
|
|
39
|
+
|---|---|---|---|
|
|
40
|
+
| [PyGithub](https://github.com/PyGithub/PyGithub) | no | typed objects | sync-only — a poor fit for anything doing concurrent, fan-out API calls |
|
|
41
|
+
| [githubkit](https://github.com/yanyongyu/githubkit) | yes | Pydantic models, generated from GitHub's OpenAPI spec | full, typed surface, but pulls in codegen'd models and `pydantic`; heavier than most scripts need |
|
|
42
|
+
| [gidgethub](https://github.com/gidgethub/gidgethub) | yes | sans-I/O, bring-your-own HTTP client | closest in spirit, but leaves auth, retry, and the client itself to the caller |
|
|
43
|
+
| [ghapi](https://ghapi.fast.ai) (fast.ai) | no | OpenAPI-generated, dynamic attribute access | lightweight like this package, but sync-only |
|
|
44
|
+
| **asyncgh** | yes | plain `dict`s, ~200 lines | no generated models, no schema — you read GitHub's own docs and index the JSON; retry and GraphQL are built in, not bolted on |
|
|
45
|
+
|
|
46
|
+
If you want typed responses and full API coverage, use `githubkit`. If you
|
|
47
|
+
want an async client with almost no code between you and the wire — a script
|
|
48
|
+
firing dozens of concurrent calls that reads a handful of fields per
|
|
49
|
+
response — that's what this is for.
|
|
50
|
+
|
|
51
|
+
## API
|
|
52
|
+
|
|
53
|
+
Full API reference, generated from the docstrings:
|
|
54
|
+
[hugoh.github.io/gh-workflows/asyncgh](https://hugoh.github.io/gh-workflows/asyncgh/)
|
|
55
|
+
(rebuilt on every push that touches this package -- see
|
|
56
|
+
`.github/workflows/docs.yml`).
|
|
57
|
+
|
|
58
|
+
## Auth
|
|
59
|
+
|
|
60
|
+
`Authorization: Bearer $(gh auth token)`, resolved lazily on the first
|
|
61
|
+
request and cached for the process. `gh` must be installed and logged in.
|
|
62
|
+
|
|
63
|
+
## Retries
|
|
64
|
+
|
|
65
|
+
`api_request` retries transport errors (DNS, timeout, reset) and transient
|
|
66
|
+
statuses — 429, 500, 502, 503, 504 — via [`stamina`](https://stamina.hynek.me).
|
|
67
|
+
A `Retry-After` header sets the exact wait; otherwise exponential backoff with
|
|
68
|
+
jitter. `GH_MAX_RETRIES` (default 3) caps the retries; there is no wall-clock
|
|
69
|
+
timeout, so a minute-long `Retry-After` is honoured in full. When the retries
|
|
70
|
+
are spent the last response is returned unchanged for the caller to judge.
|
|
71
|
+
|
|
72
|
+
`graphql()` reuses that same retry for transport errors and 429/5xx, plus one
|
|
73
|
+
addition: GitHub answers GraphQL rate limiting with HTTP 200 and an
|
|
74
|
+
`errors[].type == "RATE_LIMITED"` body, which `api_request`'s status-based
|
|
75
|
+
retry never sees -- `graphql()` retries that case itself, on the same
|
|
76
|
+
backoff. Any other GraphQL error (bad query, not found) is not transient and
|
|
77
|
+
raises immediately.
|
|
78
|
+
|
|
79
|
+
## Consumers
|
|
80
|
+
|
|
81
|
+
`repo-admin/` (in this repo) uses it for every account-wide GitHub command --
|
|
82
|
+
`repo_admin.py`'s `sync` subcommands and `activity.py` via `api_request`/
|
|
83
|
+
`api_json`/pagination; `digest.py` (wrapped by `../digest-action/`) is the
|
|
84
|
+
one driving `graphql()`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
asyncgh/__init__.py,sha256=iHHjQCUvKQUZ5sz8obhWTOS_p8XRrq2kx-oLMB6Su5o,957
|
|
2
|
+
asyncgh/client.py,sha256=L7td1RNmfqsEhFThkRTuGlGXDhx9710kx8d-nkMhaWE,9350
|
|
3
|
+
asyncgh/endpoints.py,sha256=FEDdEhRguDuHAmJRk1cec12pJA-R9Wd7SV7--yveAI0,2429
|
|
4
|
+
asyncgh/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
asyncgh-0.1.0.dist-info/METADATA,sha256=jG4Uc7q9YtZkYflQ7ndXxmn4OEZ8w9aACAdtsaM6twY,4207
|
|
6
|
+
asyncgh-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
7
|
+
asyncgh-0.1.0.dist-info/licenses/LICENSE,sha256=qCv-aayDkFcB09IGjnNETtrZ31NOA1tdCq4IJ7nZObc,1066
|
|
8
|
+
asyncgh-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hugo Haas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|