Skip to content

Commit 5086706

Browse files
committed
Update
[ghstack-poisoned]
2 parents 5cffa76 + 1fbf6d3 commit 5086706

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
@@ -89,13 +89,24 @@ def build_client(token: str, *, seconds_between_requests: float = 0.25) -> Githu
8989

9090

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

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

100111

101112
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

@@ -259,6 +260,11 @@ def run(
259260
failed: list[int] = []
260261
abandoned: list[int] = []
261262
skips: list[tuple[int, ReviewSkip]] = []
263+
# Owned here (not inside the helpers) so run can read it back after the fan-out: a rate limit
264+
# trips it, which gates the dispatch phase below. It reflects "the cancel event fired," which
265+
# is broader than a non-empty `abandoned` -- a rate limit on the last task leaves nothing to
266+
# cancel (abandoned stays empty) yet must still skip dispatch.
267+
cancel_event = threading.Event()
262268
worker_count = min(_FINGERPRINT_WORKERS, len(fingerprint_numbers))
263269
# PyGithub is not thread-safe, so each concurrent task borrows a client for its
264270
# exclusive use; sizing the pool to the worker count keeps queue.get non-blocking
@@ -283,6 +289,7 @@ def run(
283289
abandoned=abandoned,
284290
skips=skips,
285291
force=force,
292+
cancel_event=cancel_event,
286293
)
287294
else:
288295
pending = scan_runner._fingerprint_until_dispatchable(
@@ -300,17 +307,30 @@ def run(
300307
abandoned=abandoned,
301308
skips=skips,
302309
force=force,
310+
cancel_event=cancel_event,
303311
)
304-
if abandoned:
312+
dispatch_failed: list[int] = []
313+
if cancel_event.is_set():
314+
# A rate limit tripped the fan-out. The completed candidates are deferred, not lost: no
315+
# state row is written for them, so the next scan re-fingerprints and dispatches them once
316+
# the limit clears. Firing workflow_dispatch POSTs now on the same throttled token is what
317+
# GitHub's secondary-rate-limit detection punishes most, so skip the dispatch phase.
305318
logger.warning(
306-
"rate limit hit: abandoned %d of %d fingerprint(s) (not evaluated); %d candidate(s) dispatchable",
319+
"rate limit hit: abandoned %d of %d fingerprint(s) (not evaluated); "
320+
"skipping dispatch of %d completed candidate(s) this pass; will retry next scan",
307321
len(abandoned),
308322
len(fingerprint_numbers),
309323
len(pending),
310324
)
311-
dispatch_failed = scan_runner._dispatch_pending(
312-
client, pending, ref=ref, max_dispatches=max_dispatches, dispatch=dispatch, emit_dispatched=emit_dispatched
313-
)
325+
else:
326+
dispatch_failed = scan_runner._dispatch_pending(
327+
client,
328+
pending,
329+
ref=ref,
330+
max_dispatches=max_dispatches,
331+
dispatch=dispatch,
332+
emit_dispatched=emit_dispatched,
333+
)
314334
# Only the --pr recheck path posts refusals; a listing-scan skip is dropped silently
315335
# (already logged). skips can hold a refusal only when skip_on_approval is False (--pr),
316336
# 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
@@ -450,11 +450,35 @@ def test_is_rate_limit_error_true_for_github_exception_status_429():
450450

451451

452452
def test_is_rate_limit_error_false_for_github_exception_status_403():
453-
# A non-rate-limit 403 (bare GithubException, not RateLimitExceededException) is not a rate limit:
454-
# only RateLimitExceededException or a 429 count.
453+
# A bare 403 with no rate-limit headers is a plain error (e.g. a permission denial), not a limit.
455454
assert github_client.is_rate_limit_error(GithubException(403)) is False
456455

457456

457+
def test_is_rate_limit_error_true_for_403_with_retry_after_header():
458+
# A secondary-limit 403 whose body does not match PyGithub's literal rate-limit strings arrives
459+
# as a bare GithubException; the retry-after header is the robust signal that it is a rate limit.
460+
exc = GithubException(403, headers={"retry-after": "60"})
461+
assert github_client.is_rate_limit_error(exc) is True
462+
463+
464+
def test_is_rate_limit_error_true_for_403_with_exhausted_ratelimit_remaining():
465+
# A primary-limit 403 that misclassifies is still recognizable by an exhausted budget.
466+
exc = GithubException(403, headers={"x-ratelimit-remaining": "0"})
467+
assert github_client.is_rate_limit_error(exc) is True
468+
469+
470+
def test_is_rate_limit_error_false_for_403_with_budget_remaining():
471+
# A genuine permission 403 carries x-ratelimit-remaining with budget left and no retry-after, so
472+
# it must not be misread as a rate limit.
473+
exc = GithubException(403, headers={"x-ratelimit-remaining": "4999"})
474+
assert github_client.is_rate_limit_error(exc) is False
475+
476+
477+
def test_is_rate_limit_error_false_for_github_exception_other_status():
478+
# A GithubException carrying any other status (e.g. a 500) is not a rate limit.
479+
assert github_client.is_rate_limit_error(GithubException(500)) is False
480+
481+
458482
def test_is_rate_limit_error_false_for_retry_error():
459483
# A urllib3-exhausted retry surfaces as requests RetryError, not a GithubException, so it is not
460484
# classified as a rate limit.

greenlight/tests/test_review.py

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

13381338

1339-
def test_rate_limit_still_dispatches_earlier_completed_candidate(make_config, monkeypatch, caplog):
1339+
def test_rate_limit_defers_completed_candidate_without_dispatching(make_config, monkeypatch, caplog):
13401340
from github import RateLimitExceededException
13411341

13421342
monkeypatch.setattr(review, "_FINGERPRINT_WORKERS", 1)
@@ -1364,17 +1364,64 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13641364
now=lambda: _NOW,
13651365
)
13661366

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

13791426

13801427
def test_rate_limit_abandonment_breaks_max_dispatch_batches(make_config, monkeypatch):
@@ -1407,10 +1454,43 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
14071454
)
14081455

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

14151495

14161496
def test_fetch_failure_still_closes_main_client(make_config):

0 commit comments

Comments
 (0)