4343AWAITING_LABEL = "awaiting reviews"
4444HACKTOBERFEST_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``
5256ROW_RE = re .compile (
5559STATS_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+
5872def _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
104131async 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(
225273async 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