Skip to content

Commit 6dd76eb

Browse files
committed
Update
[ghstack-poisoned]
1 parent f9fbec5 commit 6dd76eb

4 files changed

Lines changed: 79 additions & 23 deletions

File tree

greenlight/src/greenlight/github_client.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,19 @@ def _build_retry() -> Retry:
7272
)
7373

7474

75-
def build_client(token: str) -> Github:
75+
def build_client(token: str, *, seconds_between_requests: float = 0.25) -> Github:
7676
from github import Auth, Github # lazy: keeps this module importable without the dep
7777

78+
# Pin PyGithub's 0.25s default pacing explicitly (cf. _GITHUB_TIMEOUT_SECONDS) so a library
79+
# default change can't silently alter our request rate; the fingerprint fan-out passes a
80+
# slower value to bound its aggregate rate.
7881
return Github(
7982
auth=Auth.Token(token),
8083
per_page=100,
8184
timeout=_GITHUB_TIMEOUT_SECONDS,
8285
retry=_build_retry(),
8386
lazy=True,
87+
seconds_between_requests=seconds_between_requests,
8488
)
8589

8690

greenlight/src/greenlight/review.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
if TYPE_CHECKING:
3636
from collections.abc import Callable, Sequence
37+
from typing import Protocol
3738

3839
from github import Github
3940

@@ -44,6 +45,11 @@
4445
from greenlight.scan_runner import FingerprintFn
4546
from greenlight.state import PRState
4647

48+
class _BuildClient(Protocol):
49+
# token positional-only so injected test doubles needn't match the parameter name.
50+
def __call__(self, token: str, /, *, seconds_between_requests: float = 0.25) -> Github: ...
51+
52+
4753
logger = logging.getLogger(__name__)
4854

4955
TRUSTED_AUTHORS: set[str] = {
@@ -65,7 +71,12 @@ def _is_trusted(login: str | None) -> bool:
6571
return login is not None and login.lower() in _TRUSTED_LOWER
6672

6773

68-
_FINGERPRINT_WORKERS = 8
74+
# The fan-out's aggregate GitHub request rate is workers / seconds-between-requests; both are
75+
# tuned down together to stay under GitHub's secondary (burst-rate) limit, which the prior
76+
# 8-worker / default-paced pool tripped. Raising either without lowering the other raises the
77+
# burst rate.
78+
_FINGERPRINT_WORKERS = 4
79+
_FINGERPRINT_SECONDS_BETWEEN_REQUESTS = 0.5
6980

7081

7182
def _utcnow() -> datetime:
@@ -183,7 +194,7 @@ def run(
183194
requester: str | None = None,
184195
allow_untrusted_author: bool = False,
185196
bot_login: str = "",
186-
build_github: Callable[[str], Github] = github_client.build_client,
197+
build_github: _BuildClient = github_client.build_client,
187198
fetch: Callable[[Github], list[OpenPR]] = _default_fetch,
188199
fetch_author: Callable[[Github, int], str | None] = _default_fetch_author,
189200
fingerprint: FingerprintFn = _default_fingerprint,
@@ -251,7 +262,7 @@ def run(
251262
# and guarantees no two running tasks ever share one.
252263
client_pool: queue.Queue[Github] = queue.Queue()
253264
for _ in range(worker_count):
254-
worker_client = build_github(token)
265+
worker_client = build_github(token, seconds_between_requests=_FINGERPRINT_SECONDS_BETWEEN_REQUESTS)
255266
clients.callback(_close_client, worker_client)
256267
client_pool.put(worker_client)
257268
if max_dispatches is None:

greenlight/tests/test_github_client.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,36 @@ def _fake_github(**kwargs: object) -> object:
407407
assert retry.respect_retry_after_header is False
408408

409409

410+
def test_build_client_passes_seconds_between_requests_when_given(monkeypatch):
411+
captured: dict[str, object] = {}
412+
413+
def _fake_github(**kwargs: object) -> object:
414+
captured.update(kwargs)
415+
return object()
416+
417+
monkeypatch.setattr("github.Github", _fake_github)
418+
419+
github_client.build_client("tok", seconds_between_requests=0.5)
420+
421+
assert captured["seconds_between_requests"] == 0.5
422+
423+
424+
def test_build_client_defaults_pacing_to_pygithub_default(monkeypatch):
425+
captured: dict[str, object] = {}
426+
427+
def _fake_github(**kwargs: object) -> object:
428+
captured.update(kwargs)
429+
return object()
430+
431+
monkeypatch.setattr("github.Github", _fake_github)
432+
433+
github_client.build_client("tok")
434+
435+
# Pins PyGithub's 0.25s default explicitly, so a library default change cannot silently alter
436+
# the request rate.
437+
assert captured["seconds_between_requests"] == 0.25
438+
439+
410440
def test_is_rate_limit_error_true_for_rate_limit_exceeded_exception():
411441
# A 403 rate limit arrives as RateLimitExceededException (status 403), matched by the isinstance
412442
# arm, not by the 429 status check.

greenlight/tests/test_review.py

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ def fake_emit(*, repo, pr_number, head_sha, eval_hash, run_id):
171171
requester=requester,
172172
allow_untrusted_author=allow_untrusted_author,
173173
bot_login=bot_login,
174-
build_github=lambda _token: _CLIENT,
174+
build_github=lambda _token, **_kwargs: _CLIENT,
175175
fetch=fake_fetch,
176176
fetch_author=fake_fetch_author,
177177
fingerprint=fake_fingerprint,
@@ -618,7 +618,7 @@ def fake_dispatch(_client, number, head_sha, eval_hash, dispatch_ref):
618618
review.run(
619619
make_config(github_token="t"),
620620
max_dispatches=1,
621-
build_github=lambda _token: _CLIENT,
621+
build_github=lambda _token, **_kwargs: _CLIENT,
622622
fetch=lambda _client: [_open_pr(n) for n in numbers],
623623
fingerprint=boom_fingerprint,
624624
read_state=lambda _repo, _numbers: {},
@@ -856,7 +856,7 @@ def fake_dispatch(_client, number, head_sha, eval_hash, dispatch_ref):
856856
make_config(github_token="t"),
857857
pr=7,
858858
force=True,
859-
build_github=lambda _token: _CLIENT,
859+
build_github=lambda _token, **_kwargs: _CLIENT,
860860
fetch=lambda _client: [],
861861
fetch_author=lambda _client, _number: "albanD",
862862
fingerprint=boom_fingerprint,
@@ -955,7 +955,7 @@ def fake_dispatch(_client, number, head_sha, eval_hash, dispatch_ref):
955955
):
956956
review.run(
957957
make_config(github_token="t"),
958-
build_github=lambda _token: _CLIENT,
958+
build_github=lambda _token, **_kwargs: _CLIENT,
959959
fetch=lambda _client: [_open_pr(1), _open_pr(2), _open_pr(3)],
960960
fingerprint=boom_fingerprint,
961961
read_state=lambda _repo, _numbers: {2: _state(2, STATUS_LAND, _HASH_A, _NEW)},
@@ -989,7 +989,7 @@ def fake_dispatch(_client, number, head_sha, eval_hash, dispatch_ref):
989989
):
990990
review.run(
991991
make_config(github_token="t"),
992-
build_github=lambda _token: _CLIENT,
992+
build_github=lambda _token, **_kwargs: _CLIENT,
993993
fetch=lambda _client: [_open_pr(3), _open_pr(1), _open_pr(2)],
994994
fingerprint=boom_fingerprint,
995995
read_state=lambda _repo, _numbers: {},
@@ -1020,7 +1020,7 @@ def boom_dispatch(_client, number, _head_sha, _eval_hash, _ref):
10201020
):
10211021
review.run(
10221022
make_config(github_token="t"),
1023-
build_github=lambda _token: _CLIENT,
1023+
build_github=lambda _token, **_kwargs: _CLIENT,
10241024
fetch=lambda _client: [_open_pr(1), _open_pr(2), _open_pr(3)],
10251025
fingerprint=lambda _client, number, _authorized, _skip: (f"headsha{number}", _HASH_A),
10261026
read_state=lambda _repo, _numbers: {},
@@ -1049,7 +1049,7 @@ def boom_dispatch(_client, number, _head_sha, _eval_hash, _ref):
10491049
):
10501050
review.run(
10511051
make_config(github_token="t"),
1052-
build_github=lambda _token: _CLIENT,
1052+
build_github=lambda _token, **_kwargs: _CLIENT,
10531053
fetch=lambda _client: [_open_pr(1), _open_pr(2), _open_pr(3)],
10541054
fingerprint=lambda _client, number, _authorized, _skip: (f"headsha{number}", _HASH_A),
10551055
read_state=lambda _repo, _numbers: {},
@@ -1074,7 +1074,7 @@ def timeout_dispatch(_client, number, _head_sha, _eval_hash, _ref):
10741074
with pytest.raises(IterationTimeout):
10751075
review.run(
10761076
make_config(github_token="t"),
1077-
build_github=lambda _token: _CLIENT,
1077+
build_github=lambda _token, **_kwargs: _CLIENT,
10781078
fetch=lambda _client: [_open_pr(1), _open_pr(2), _open_pr(3)],
10791079
fingerprint=lambda _client, number, _authorized, _skip: (f"headsha{number}", _HASH_A),
10801080
read_state=lambda _repo, _numbers: {},
@@ -1105,7 +1105,7 @@ def boom_dispatch(_client, number, _head_sha, _eval_hash, _ref):
11051105
with caplog.at_level(logging.ERROR, logger="greenlight"), pytest.raises(RuntimeError) as excinfo:
11061106
review.run(
11071107
make_config(github_token="t"),
1108-
build_github=lambda _token: _CLIENT,
1108+
build_github=lambda _token, **_kwargs: _CLIENT,
11091109
fetch=lambda _client: [_open_pr(1), _open_pr(2), _open_pr(3)],
11101110
fingerprint=boom_fingerprint,
11111111
read_state=lambda _repo, _numbers: {},
@@ -1153,7 +1153,7 @@ def fake_dispatch(_client, number, head_sha, eval_hash, dispatch_ref):
11531153

11541154
review.run(
11551155
make_config(github_token="t"),
1156-
build_github=lambda _token: _CLIENT,
1156+
build_github=lambda _token, **_kwargs: _CLIENT,
11571157
fetch=lambda _client: [_open_pr(n) for n in numbers],
11581158
fingerprint=barrier_fingerprint,
11591159
read_state=lambda _repo, _numbers: {},
@@ -1176,7 +1176,7 @@ def test_worker_clients_are_isolated_and_exclude_main_client(make_config):
11761176
barrier = threading.Barrier(k, timeout=5)
11771177
built: list[object] = []
11781178

1179-
def factory(_token):
1179+
def factory(_token, **_kwargs):
11801180
client = object()
11811181
built.append(client)
11821182
return cast("Github", client)
@@ -1218,16 +1218,17 @@ def fake_dispatch(_client, number, head_sha, eval_hash, dispatch_ref):
12181218

12191219
def test_run_closes_main_and_worker_clients(make_config):
12201220
class _Closeable:
1221-
def __init__(self) -> None:
1221+
def __init__(self, seconds_between_requests: float | None) -> None:
12221222
self.closed = 0
1223+
self.seconds_between_requests = seconds_between_requests
12231224

12241225
def close(self) -> None:
12251226
self.closed += 1
12261227

12271228
built: list[_Closeable] = []
12281229

1229-
def factory(_token):
1230-
client = _Closeable()
1230+
def factory(_token, *, seconds_between_requests=None):
1231+
client = _Closeable(seconds_between_requests)
12311232
built.append(client)
12321233
return cast("Github", client)
12331234

@@ -1246,6 +1247,16 @@ def factory(_token):
12461247
# each is closed exactly once as the scan unwinds -- the connection pools are not leaked.
12471248
assert len(built) == 3
12481249
assert all(client.closed == 1 for client in built)
1250+
# The main client (built first) keeps PyGithub's default pacing -- no kwarg passed -- while the
1251+
# two fingerprint worker clients are throttled to bound the fan-out's aggregate request rate.
1252+
assert built[0].seconds_between_requests is None
1253+
assert [c.seconds_between_requests for c in built[1:]] == [review._FINGERPRINT_SECONDS_BETWEEN_REQUESTS] * 2
1254+
1255+
1256+
def test_fingerprint_throttle_stays_under_burst_limit():
1257+
# Aggregate fan-out rate is workers / seconds-between-requests; keep it <= 8 req/s to stay
1258+
# under GitHub's secondary (burst-rate) limit.
1259+
assert review._FINGERPRINT_WORKERS / review._FINGERPRINT_SECONDS_BETWEEN_REQUESTS <= 8
12491260

12501261

12511262
def test_close_client_swallows_close_errors(caplog):
@@ -1302,7 +1313,7 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13021313
with pytest.raises(RuntimeError, match=r"1 PR\(s\) failed during scan: \[1\]"):
13031314
review.run(
13041315
make_config(github_token="t"),
1305-
build_github=lambda _token: _CLIENT,
1316+
build_github=lambda _token, **_kwargs: _CLIENT,
13061317
fetch=lambda _client: [_open_pr(1), _open_pr(2), _open_pr(3)],
13071318
fingerprint=fingerprint,
13081319
read_state=lambda _repo, _numbers: {},
@@ -1337,7 +1348,7 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13371348
with pytest.raises(RuntimeError, match=r"1 PR\(s\) failed during scan: \[2\]"):
13381349
review.run(
13391350
make_config(github_token="t"),
1340-
build_github=lambda _token: _CLIENT,
1351+
build_github=lambda _token, **_kwargs: _CLIENT,
13411352
fetch=lambda _client: [_open_pr(1), _open_pr(2), _open_pr(3)],
13421353
fingerprint=fingerprint,
13431354
read_state=lambda _repo, _numbers: {},
@@ -1373,7 +1384,7 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13731384
review.run(
13741385
make_config(github_token="t"),
13751386
max_dispatches=5,
1376-
build_github=lambda _token: _CLIENT,
1387+
build_github=lambda _token, **_kwargs: _CLIENT,
13771388
fetch=lambda _client: [_open_pr(1), _open_pr(2), _open_pr(3)],
13781389
fingerprint=fingerprint,
13791390
read_state=lambda _repo, _numbers: {},
@@ -1398,7 +1409,7 @@ def boom_fetch(_client):
13981409
with pytest.raises(RuntimeError, match="fetch boom"):
13991410
review.run(
14001411
make_config(github_token="t"),
1401-
build_github=lambda _token: cast("Github", client),
1412+
build_github=lambda _token, **_kwargs: cast("Github", client),
14021413
fetch=boom_fetch,
14031414
fingerprint=lambda _client, number, _authorized, _skip: (f"headsha{number}", _HASH_A),
14041415
read_state=lambda _repo, _numbers: {},
@@ -1471,7 +1482,7 @@ def boom_resolve() -> frozenset[str]:
14711482
with pytest.raises(RuntimeError, match="merge_rules unreachable"):
14721483
review.run(
14731484
make_config(github_token="t"),
1474-
build_github=lambda _token: _CLIENT,
1485+
build_github=lambda _token, **_kwargs: _CLIENT,
14751486
fetch=lambda _client: [_open_pr(1)],
14761487
fingerprint=lambda _client, number, _authorized, _skip: (f"headsha{number}", _HASH_A),
14771488
read_state=lambda _repo, _numbers: {},

0 commit comments

Comments
 (0)