Skip to content

Commit 3f66155

Browse files
fix(ci): make tracker refresh degrade gracefully when rate limited
The dry run was failing because a run can exhaust the GITHUB_TOKEN's 1000/hour-per-repo budget (shared across concurrent runs) — chiefly the awaiting-reviews directory scan. A single exhausted request then raised and killed the whole job. - _request now honours Retry-After (secondary limits) and, once retries are exhausted, raises BestEffortError instead of a bare RuntimeError. - Row resolution, the directory scan, and the search counts catch BestEffortError and degrade (keep the row / mark the stat unavailable) instead of failing. Only the post-Oct-1 retirement exits non-zero. - Trim the directory scan to 120 PRs and CONCURRENCY to 5 to stay well under the shared budget in the first place.
1 parent 049c7df commit 3f66155

1 file changed

Lines changed: 97 additions & 31 deletions

File tree

scripts/hacktoberfest_prep_update.py

Lines changed: 97 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,14 @@
4343
AWAITING_LABEL = "awaiting reviews"
4444
HACKTOBERFEST_START = dt.date(2026, 10, 1)
4545

46-
# How many API requests to keep in flight at once. GitHub's authenticated
47-
# primary limit is 5000/hour, but bursts of concurrent requests can trip the
48-
# secondary limits, so keep this modest.
49-
CONCURRENCY = 8
46+
# How many API requests to keep in flight at once. A user token's primary
47+
# limit is 5000/hour, but the ``GITHUB_TOKEN`` the runner hands us is capped at
48+
# 1000/hour *per repository* and shared across every concurrent workflow run, so
49+
# several dry runs in the same hour can exhaust it between them. Bursts of
50+
# concurrent requests can also trip the secondary limits. Keep this modest and
51+
# let the callers degrade gracefully when a request can't be satisfied (see
52+
# ``BestEffortError``) rather than failing the whole job.
53+
CONCURRENCY = 5
5054

5155
# A tracked row looks like: ``12. [ ] #15144 awaiting reviews``
5256
ROW_RE = re.compile(
@@ -55,6 +59,16 @@
5559
STATS_HEADER = "## Automated statistics"
5660

5761

62+
class BestEffortError(RuntimeError):
63+
"""A row/statistic could not be fetched (e.g. rate limited).
64+
65+
Raised by :func:`_request` once every retry is exhausted. The refresh is
66+
best-effort: callers catch this so an unreachable API degrades the tracker
67+
(keep the old value / omit a stat) instead of failing the whole job. The
68+
only intentional non-zero exit is the post-Oct-1 retirement.
69+
"""
70+
71+
5872
def _log(message: str) -> None:
5973
"""Emit a progress line to stderr, flushed so Actions shows it live."""
6074
print(message, file=sys.stderr, flush=True)
@@ -86,19 +100,32 @@ async def _request(
86100
resp = await client.get(url, params=params)
87101
if resp.is_success:
88102
return resp.json(), dict(resp.headers)
89-
remaining = resp.headers.get("X-RateLimit-Remaining")
90-
if resp.status_code in (403, 429) and remaining == "0":
91-
reset = int(resp.headers.get("X-RateLimit-Reset", "0"))
92-
wait = max(1, reset - int(time.time())) + 1
93-
_log(f"Rate limited on {url}; sleeping {min(wait, 90)}s")
94-
await asyncio.sleep(min(wait, 90))
95-
continue
103+
# Both primary ("remaining == 0") and secondary/abuse rate limits
104+
# come back as 403/429. Primary limits advertise a reset epoch;
105+
# secondary limits instead send a ``Retry-After`` (seconds) and may
106+
# still report a non-zero remaining, so honour either signal.
107+
if resp.status_code in (403, 429):
108+
remaining = resp.headers.get("X-RateLimit-Remaining")
109+
retry_after = resp.headers.get("Retry-After")
110+
if retry_after is not None:
111+
wait = int(retry_after) + 1
112+
elif remaining == "0":
113+
reset = int(resp.headers.get("X-RateLimit-Reset", "0"))
114+
wait = max(1, reset - int(time.time())) + 1
115+
else:
116+
wait = 0
117+
if wait and attempt < 3:
118+
_log(f"Rate limited on {url}; sleeping {min(wait, 90)}s")
119+
await asyncio.sleep(min(wait, 90))
120+
continue
96121
if resp.status_code >= 500 and attempt < 3:
97122
await asyncio.sleep(2 * (attempt + 1))
98123
continue
99124
resp.raise_for_status()
100-
msg = f"giving up on {url}"
101-
raise RuntimeError(msg)
125+
# Exhausted every retry (typically the shared per-repo budget ran dry).
126+
# Signal the callers to degrade rather than crash the whole run.
127+
msg = f"giving up on {url} after repeated rate limiting"
128+
raise BestEffortError(msg)
102129

103130

104131
async def _search_count(
@@ -135,7 +162,7 @@ async def top_awaiting_directories(
135162
client: httpx2.AsyncClient,
136163
sem: asyncio.Semaphore,
137164
limit: int = 3,
138-
max_prs: int = 400,
165+
max_prs: int = 120,
139166
) -> list[tuple[str, int]]:
140167
"""Count open ``awaiting reviews`` PRs by the top-level directory they touch."""
141168
query = f'repo:{REPO} is:pr is:open label:"{AWAITING_LABEL}"'
@@ -176,12 +203,22 @@ async def dirs_for(number: int) -> set[str]:
176203
_log(f" ...scanned {done}/{total} PR(s)")
177204
return dirs
178205

179-
results = await asyncio.gather(*(dirs_for(n) for n in numbers))
206+
results = await asyncio.gather(
207+
*(dirs_for(n) for n in numbers), return_exceptions=True
208+
)
180209

181210
counts: dict[str, int] = {}
211+
skipped = 0
182212
for dirs in results:
213+
if isinstance(dirs, BestEffortError):
214+
skipped += 1
215+
continue
216+
if isinstance(dirs, BaseException):
217+
raise dirs
183218
for directory in dirs:
184219
counts[directory] = counts.get(directory, 0) + 1
220+
if skipped:
221+
_log(f" ...{skipped} PR(s) skipped (API unavailable); ranking partial.")
185222
ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
186223
return ranked[:limit]
187224

@@ -199,12 +236,23 @@ async def refresh_checkboxes(
199236
]
200237
if pending:
201238
_log(f"Checking {len(pending)} open tracker row(s) for resolution...")
202-
states = dict(
203-
zip(
204-
pending,
205-
await asyncio.gather(*(pr_state(client, sem, n) for n in pending)),
206-
)
239+
# ``return_exceptions`` keeps one rate-limited row from cancelling the rest:
240+
# a row we couldn't resolve is simply left unchanged (treated as ``None``).
241+
resolved = await asyncio.gather(
242+
*(pr_state(client, sem, n) for n in pending), return_exceptions=True
207243
)
244+
states: dict[int, str | None] = {}
245+
unresolved = 0
246+
for number, result in zip(pending, resolved):
247+
if isinstance(result, BestEffortError):
248+
unresolved += 1
249+
states[number] = None
250+
elif isinstance(result, BaseException):
251+
raise result
252+
else:
253+
states[number] = result
254+
if unresolved:
255+
_log(f" ...{unresolved} row(s) left unchanged (API unavailable).")
208256

209257
updated = 0
210258
out: list[str] = []
@@ -225,13 +273,23 @@ async def refresh_checkboxes(
225273
async def build_stats_block(client: httpx2.AsyncClient, sem: asyncio.Semaphore) -> str:
226274
_log("Collecting open issue/PR counts...")
227275
awaiting_query = f'repo:{REPO} is:pr is:open label:"{AWAITING_LABEL}"'
276+
277+
async def _count_or_none(query: str) -> int | None:
278+
try:
279+
return await _search_count(client, sem, query)
280+
except BestEffortError:
281+
return None
282+
228283
open_issues, open_prs, awaiting = await asyncio.gather(
229-
_search_count(client, sem, f"repo:{REPO} is:issue is:open"),
230-
_search_count(client, sem, f"repo:{REPO} is:pr is:open"),
231-
_search_count(client, sem, awaiting_query),
284+
_count_or_none(f"repo:{REPO} is:issue is:open"),
285+
_count_or_none(f"repo:{REPO} is:pr is:open"),
286+
_count_or_none(awaiting_query),
232287
)
233288
today = dt.datetime.now(dt.UTC).date().isoformat()
234289

290+
def _fmt(value: int | None) -> str:
291+
return str(value) if value is not None else "unavailable (rate limited)"
292+
235293
lines = [
236294
STATS_HEADER,
237295
"",
@@ -240,22 +298,30 @@ async def build_stats_block(client: httpx2.AsyncClient, sem: asyncio.Semaphore)
240298
f"`scripts/hacktoberfest_prep_update.py` on {today} (UTC)._"
241299
),
242300
"",
243-
f"- **Open issues:** {open_issues}",
244-
f"- **Open pull requests:** {open_prs}",
245-
f"- **Open PRs labelled `{AWAITING_LABEL}`:** {awaiting}",
301+
f"- **Open issues:** {_fmt(open_issues)}",
302+
f"- **Open pull requests:** {_fmt(open_prs)}",
303+
f"- **Open PRs labelled `{AWAITING_LABEL}`:** {_fmt(awaiting)}",
246304
"",
247305
(
248306
"**Top three directories to work on** (most open pull requests "
249307
f"labelled `{AWAITING_LABEL}`):"
250308
),
251309
"",
252310
]
253-
if top_dirs := await top_awaiting_directories(client, sem):
254-
for rank, (directory, count) in enumerate(top_dirs, start=1):
255-
plural = "PR" if count == 1 else "PRs"
256-
lines.append(f"{rank}. `{directory}/` — {count} awaiting-reviews {plural}")
311+
try:
312+
top_dirs = await top_awaiting_directories(client, sem)
313+
except BestEffortError:
314+
top_dirs = []
315+
lines.append("_Directory ranking unavailable this run (rate limited)._")
257316
else:
258-
lines.append("_No open `awaiting reviews` pull requests found._")
317+
if top_dirs:
318+
for rank, (directory, count) in enumerate(top_dirs, start=1):
319+
plural = "PR" if count == 1 else "PRs"
320+
lines.append(
321+
f"{rank}. `{directory}/` — {count} awaiting-reviews {plural}"
322+
)
323+
else:
324+
lines.append("_No open `awaiting reviews` pull requests found._")
259325
lines.append("")
260326
return "\n".join(lines)
261327

0 commit comments

Comments
 (0)