Skip to content

Commit 1ea3b42

Browse files
committed
Update
[ghstack-poisoned]
1 parent acc53d4 commit 1ea3b42

5 files changed

Lines changed: 158 additions & 23 deletions

File tree

greenlight/src/greenlight/github_client.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,24 @@ def build_client(token: str) -> Github:
8585

8686

8787
def is_rate_limit_error(exc: BaseException) -> bool:
88-
# GitHub delivers a rate limit as 403 (-> RateLimitExceededException) or 429 (-> base
88+
# GitHub delivers a rate limit as 403 (usually -> RateLimitExceededException) or 429 (-> base
8989
# GithubException); 429 must stay off _build_retry's forcelist or it surfaces as a RetryError.
9090
from github import GithubException, RateLimitExceededException
9191

9292
if isinstance(exc, RateLimitExceededException):
9393
return True
94-
return isinstance(exc, GithubException) and getattr(exc, "status", None) == 429
94+
if not isinstance(exc, GithubException):
95+
return False
96+
status = getattr(exc, "status", None)
97+
if status == 429:
98+
return True
99+
# PyGithub only maps a 403 to RateLimitExceededException when the body matches one of a few
100+
# literal message strings, so a rate-limit 403 whose wording drifts arrives as a bare
101+
# GithubException; key off the rate-limit headers GitHub sends with it instead of the message.
102+
if status == 403:
103+
headers = getattr(exc, "headers", None) or {}
104+
return "retry-after" in headers or headers.get("x-ratelimit-remaining") == "0"
105+
return False
95106

96107

97108
def list_open_prs_by_authors(client: _RepoClient, repo: str, authors: Iterable[str]) -> list[OpenPR]:

greenlight/src/greenlight/review.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import contextlib
2020
import logging
2121
import queue
22+
import threading
2223
from datetime import UTC, datetime, timedelta
2324
from typing import TYPE_CHECKING
2425

@@ -248,6 +249,11 @@ def run(
248249
failed: list[int] = []
249250
abandoned: list[int] = []
250251
skips: list[tuple[int, ReviewSkip]] = []
252+
# Owned here (not inside the helpers) so run can read it back after the fan-out: a rate limit
253+
# trips it, which gates the dispatch phase below. It reflects "the cancel event fired," which
254+
# is broader than a non-empty `abandoned` -- a rate limit on the last task leaves nothing to
255+
# cancel (abandoned stays empty) yet must still skip dispatch.
256+
cancel_event = threading.Event()
251257
worker_count = min(_FINGERPRINT_WORKERS, len(fingerprint_numbers))
252258
# PyGithub is not thread-safe, so each concurrent task borrows a client for its
253259
# exclusive use; sizing the pool to the worker count keeps queue.get non-blocking
@@ -272,6 +278,7 @@ def run(
272278
abandoned=abandoned,
273279
skips=skips,
274280
force=force,
281+
cancel_event=cancel_event,
275282
)
276283
else:
277284
pending = scan_runner._fingerprint_until_dispatchable(
@@ -289,17 +296,30 @@ def run(
289296
abandoned=abandoned,
290297
skips=skips,
291298
force=force,
299+
cancel_event=cancel_event,
292300
)
293-
if abandoned:
301+
dispatch_failed: list[int] = []
302+
if cancel_event.is_set():
303+
# A rate limit tripped the fan-out. The completed candidates are deferred, not lost: no
304+
# state row is written for them, so the next scan re-fingerprints and dispatches them once
305+
# the limit clears. Firing workflow_dispatch POSTs now on the same throttled token is what
306+
# GitHub's secondary-rate-limit detection punishes most, so skip the dispatch phase.
294307
logger.warning(
295-
"rate limit hit: abandoned %d of %d fingerprint(s) (not evaluated); %d candidate(s) dispatchable",
308+
"rate limit hit: abandoned %d of %d fingerprint(s) (not evaluated); "
309+
"skipping dispatch of %d completed candidate(s) this pass; will retry next scan",
296310
len(abandoned),
297311
len(fingerprint_numbers),
298312
len(pending),
299313
)
300-
dispatch_failed = scan_runner._dispatch_pending(
301-
client, pending, ref=ref, max_dispatches=max_dispatches, dispatch=dispatch, emit_dispatched=emit_dispatched
302-
)
314+
else:
315+
dispatch_failed = scan_runner._dispatch_pending(
316+
client,
317+
pending,
318+
ref=ref,
319+
max_dispatches=max_dispatches,
320+
dispatch=dispatch,
321+
emit_dispatched=emit_dispatched,
322+
)
303323
# Only the --pr recheck path posts refusals; a listing-scan skip is dropped silently
304324
# (already logged). skips can hold a refusal only when skip_on_approval is False (--pr),
305325
# so this can never comment on a listing-scan approval.

greenlight/src/greenlight/scan_runner.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from __future__ import annotations
1010

1111
import logging
12-
import threading
1312
from concurrent.futures import ThreadPoolExecutor
1413
from dataclasses import dataclass
1514
from datetime import datetime
@@ -24,6 +23,7 @@
2423

2524
if TYPE_CHECKING:
2625
import queue
26+
import threading
2727
from collections.abc import Callable, Sequence
2828
from concurrent.futures import Future
2929
from datetime import timedelta
@@ -160,8 +160,8 @@ def _fingerprint_all(
160160
abandoned: list[int],
161161
skips: list[tuple[int, ReviewSkip]],
162162
force: bool,
163+
cancel_event: threading.Event,
163164
) -> list[_Candidate]:
164-
cancel_event = threading.Event()
165165
futures: dict[int, Future[tuple[str, str] | ReviewSkip | _Cancelled]] = {}
166166
if worker_count:
167167
with ThreadPoolExecutor(max_workers=worker_count) as pool:
@@ -211,10 +211,10 @@ def _fingerprint_until_dispatchable(
211211
abandoned: list[int],
212212
skips: list[tuple[int, ReviewSkip]],
213213
force: bool,
214+
cancel_event: threading.Event,
214215
) -> list[_Candidate]:
215216
ranked = sorted(pr_numbers, key=lambda number: _staleness_key_for_state(states.get(number)))
216217
pending: list[_Candidate] = []
217-
cancel_event = threading.Event()
218218
if worker_count:
219219
with ThreadPoolExecutor(max_workers=worker_count) as pool:
220220
for start in range(0, len(ranked), worker_count):

greenlight/tests/test_github_client.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -420,11 +420,35 @@ def test_is_rate_limit_error_true_for_github_exception_status_429():
420420

421421

422422
def test_is_rate_limit_error_false_for_github_exception_status_403():
423-
# A non-rate-limit 403 (bare GithubException, not RateLimitExceededException) is not a rate limit:
424-
# only RateLimitExceededException or a 429 count.
423+
# A bare 403 with no rate-limit headers is a plain error (e.g. a permission denial), not a limit.
425424
assert github_client.is_rate_limit_error(GithubException(403)) is False
426425

427426

427+
def test_is_rate_limit_error_true_for_403_with_retry_after_header():
428+
# A secondary-limit 403 whose body does not match PyGithub's literal rate-limit strings arrives
429+
# as a bare GithubException; the retry-after header is the robust signal that it is a rate limit.
430+
exc = GithubException(403, headers={"retry-after": "60"})
431+
assert github_client.is_rate_limit_error(exc) is True
432+
433+
434+
def test_is_rate_limit_error_true_for_403_with_exhausted_ratelimit_remaining():
435+
# A primary-limit 403 that misclassifies is still recognizable by an exhausted budget.
436+
exc = GithubException(403, headers={"x-ratelimit-remaining": "0"})
437+
assert github_client.is_rate_limit_error(exc) is True
438+
439+
440+
def test_is_rate_limit_error_false_for_403_with_budget_remaining():
441+
# A genuine permission 403 carries x-ratelimit-remaining with budget left and no retry-after, so
442+
# it must not be misread as a rate limit.
443+
exc = GithubException(403, headers={"x-ratelimit-remaining": "4999"})
444+
assert github_client.is_rate_limit_error(exc) is False
445+
446+
447+
def test_is_rate_limit_error_false_for_github_exception_other_status():
448+
# A GithubException carrying any other status (e.g. a 500) is not a rate limit.
449+
assert github_client.is_rate_limit_error(GithubException(500)) is False
450+
451+
428452
def test_is_rate_limit_error_false_for_retry_error():
429453
# A urllib3-exhausted retry surfaces as requests RetryError, not a GithubException, so it is not
430454
# classified as a rate limit.

greenlight/tests/test_review.py

Lines changed: 91 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1325,7 +1325,7 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13251325
assert "abandoned 2 of 3" in caplog.text
13261326

13271327

1328-
def test_rate_limit_still_dispatches_earlier_completed_candidate(make_config, monkeypatch, caplog):
1328+
def test_rate_limit_defers_completed_candidate_without_dispatching(make_config, monkeypatch, caplog):
13291329
from github import RateLimitExceededException
13301330

13311331
monkeypatch.setattr(review, "_FINGERPRINT_WORKERS", 1)
@@ -1353,17 +1353,64 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13531353
now=lambda: _NOW,
13541354
)
13551355

1356-
# PR1 completes as a candidate before PR2 hits the rate limit, so the partial success is preserved
1357-
# -- PR1 still dispatches. PR2 trips the cancel event, so PR3 short-circuits without being
1358-
# fingerprinted: PR2 lands in `failed`, PR3 is counted as abandoned (not failed).
1356+
# PR1 completes as a candidate before PR2 hits the rate limit, but a dispatch is a workflow_dispatch
1357+
# POST on the same throttled token -- exactly what secondary-rate-limit detection punishes -- so the
1358+
# whole dispatch phase is skipped once the cancel event trips. PR1 is deferred, not lost: no state
1359+
# row is written, so the next scan re-fingerprints and dispatches it once the limit clears. PR2 trips
1360+
# the event, so PR3 short-circuits without being fingerprinted.
13591361
assert fingerprinted == [1, 2]
1360-
assert dispatched == [1]
1361-
# The failure and the abandonment are surfaced distinctly in the fail-closed signal, and the
1362-
# abandonment is logged as its own warning rather than folded into the failure count.
1362+
assert dispatched == []
1363+
# The deferral is surfaced by the merged rate-limit warning, and the scan still fails closed: PR2
1364+
# (failed) and PR3 (abandoned) are carried distinctly in the end-of-scan RuntimeError.
1365+
assert "skipping dispatch of 1 completed candidate(s) this pass" in caplog.text
1366+
assert "abandoned 1 of 3" in caplog.text
13631367
message = str(excinfo.value)
13641368
assert "1 PR(s) failed during scan: [2]" in message
13651369
assert "1 PR(s) abandoned due to rate limit: [3]" in message
1366-
assert "abandoned 1 of 3" in caplog.text
1370+
1371+
1372+
def test_rate_limit_on_last_task_skips_dispatch_with_no_abandoned(make_config, monkeypatch, caplog):
1373+
from github import RateLimitExceededException
1374+
1375+
# The gate keys off cancel_event, not the `abandoned` list, precisely for this case: the rate limit
1376+
# hits the LAST task to run, so no queued task is left to short-circuit to _CANCELLED and `abandoned`
1377+
# stays empty -- yet the event IS set, so dispatch must still be skipped. A regression to `if abandoned:`
1378+
# would pass every other test but wrongly dispatch the completed candidates onto the throttled token here.
1379+
monkeypatch.setattr(review, "_FINGERPRINT_WORKERS", 1)
1380+
fingerprinted: list[int] = []
1381+
dispatched: list[int] = []
1382+
1383+
def fingerprint(_client, number, _authorized, _skip):
1384+
fingerprinted.append(number)
1385+
if number == 3:
1386+
raise RateLimitExceededException(403)
1387+
return (f"headsha{number}", _HASH_A)
1388+
1389+
def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
1390+
dispatched.append(number)
1391+
1392+
with caplog.at_level(logging.WARNING, logger="greenlight"), pytest.raises(RuntimeError) as excinfo:
1393+
review.run(
1394+
make_config(github_token="t"),
1395+
build_github=lambda _token, **_kwargs: _CLIENT,
1396+
fetch=lambda _client: [_open_pr(1), _open_pr(2), _open_pr(3)],
1397+
fingerprint=fingerprint,
1398+
read_state=lambda _repo, _numbers: {},
1399+
dispatch=fake_dispatch,
1400+
resolve_authorized=lambda: _AUTHORIZED,
1401+
now=lambda: _NOW,
1402+
)
1403+
1404+
# PR1 and PR2 complete as candidates; PR3 (the last task) trips the cancel event with nothing left to
1405+
# cancel, so abandoned is empty. Dispatch is still skipped -- the two completed candidates are deferred.
1406+
assert fingerprinted == [1, 2, 3]
1407+
assert dispatched == []
1408+
assert "skipping dispatch of 2 completed candidate(s) this pass" in caplog.text
1409+
# abandoned is empty, so the fail-closed RuntimeError carries only the failed clause (the last PR) and
1410+
# no "abandoned" clause -- the scan still signals incomplete via the rate-limited task landing in failed.
1411+
message = str(excinfo.value)
1412+
assert "1 PR(s) failed during scan: [3]" in message
1413+
assert "abandoned" not in message
13671414

13681415

13691416
def test_rate_limit_abandonment_breaks_max_dispatch_batches(make_config, monkeypatch):
@@ -1396,10 +1443,43 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13961443
)
13971444

13981445
# The capped path (_fingerprint_until_dispatchable) fingerprints in worker-sized batches (size 1
1399-
# here). PR1 dispatches, PR2 hits the rate limit and trips the cancel event, so the batch loop
1400-
# breaks before submitting PR3 -- the cap of 5 is never the limiter, the cancellation is.
1446+
# here). PR1 completes, PR2 hits the rate limit and trips the cancel event, so the batch loop
1447+
# breaks before submitting PR3 -- the cap of 5 is never the limiter, the cancellation is. The
1448+
# tripped event then gates the dispatch phase, so PR1 is deferred rather than dispatched.
14011449
assert fingerprinted == [1, 2]
1402-
assert dispatched == [1]
1450+
assert dispatched == []
1451+
1452+
1453+
def test_normal_scan_dispatches_when_not_rate_limited(make_config, monkeypatch, caplog):
1454+
# Guard against over-gating: with no rate limit the cancel event never trips, so the dispatch phase
1455+
# must run exactly as before -- every completed candidate is dispatched and no deferral warning fires.
1456+
monkeypatch.setattr(review, "_FINGERPRINT_WORKERS", 1)
1457+
fingerprinted: list[int] = []
1458+
dispatched: list[int] = []
1459+
1460+
def fingerprint(_client, number, _authorized, _skip):
1461+
fingerprinted.append(number)
1462+
return (f"headsha{number}", _HASH_A)
1463+
1464+
def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
1465+
dispatched.append(number)
1466+
1467+
with caplog.at_level(logging.WARNING, logger="greenlight"):
1468+
review.run(
1469+
make_config(github_token="t"),
1470+
build_github=lambda _token, **_kwargs: _CLIENT,
1471+
fetch=lambda _client: [_open_pr(1), _open_pr(2), _open_pr(3)],
1472+
fingerprint=fingerprint,
1473+
read_state=lambda _repo, _numbers: {},
1474+
dispatch=fake_dispatch,
1475+
resolve_authorized=lambda: _AUTHORIZED,
1476+
now=lambda: _NOW,
1477+
)
1478+
1479+
assert fingerprinted == [1, 2, 3]
1480+
assert dispatched == [1, 2, 3]
1481+
assert "skipping dispatch" not in caplog.text
1482+
assert "rate limit" not in caplog.text
14031483

14041484

14051485
def test_fetch_failure_still_closes_main_client(make_config):

0 commit comments

Comments
 (0)