Skip to content

Commit 9c4bf4b

Browse files
authored
Bound the fingerprint fan-out's request rate (#8508)
Stack from [ghstack](https://github.com/ezyang/ghstack/tree/0.14.0) (oldest at bottom): * #8568 * __->__ #8508 * #8507 * #8506 * #8505 **Impact:** greenlight scan (fingerprint fan-out) **Risk:** low ## What Halve the fingerprint pool to 4 workers and pace each worker client at 0.5s between requests (PyGithub's built-in pacer), via a new build_client keyword. Only the fan-out worker clients are slowed; the listing/dispatch/verdict/authz clients keep the 0.25s default. ## Why The 8-worker pool issued ~32 concurrent requests/second, which trips GitHub's secondary (burst-rate) limit even though it is well under the 100-concurrent ceiling -- GitHub's guidance is to issue requests serially, not in a wide burst. Four workers at 0.5s cap the fan-out at ~8 req/s (~4x lower), under the secondary limit with wide margin, while the listing client (which paginates a large open-PR set) is left at full speed since it is sequential, not the burst. # Notes Aggregate fan-out rate is workers / seconds-between-requests: more workers or a shorter interval raise it; cutting workers 8 -> 4 and lengthening the interval 0.25s -> 0.5s both lower it, to ~8 req/s (a test pins the invariant at <= 8 req/s). build_client pins PyGithub's 0.25s default explicitly so a library default change cannot silently alter the request rate. Pure concurrency/pacing change: fingerprint results, decisions, and dispatch are unaffected, and a rate-limited or slow fan-out remains fail-closed. Signed-off-by: Jean Schmidt <contato@jschmidt.me>
1 parent 5024b0f commit 9c4bf4b

4 files changed

Lines changed: 82 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
@@ -35,6 +35,7 @@
3535

3636
if TYPE_CHECKING:
3737
from collections.abc import Callable, Sequence
38+
from typing import Protocol
3839

3940
from github import Github
4041

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

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

5056
TRUSTED_AUTHORS: set[str] = {
@@ -68,7 +74,12 @@ def _is_trusted(login: str | None) -> bool:
6874
return login is not None and login.lower() in _TRUSTED_LOWER
6975

7076

71-
_FINGERPRINT_WORKERS = 8
77+
# Aggregate fan-out request rate is workers / seconds-between-requests: more workers or a
78+
# shorter interval raise it. Cutting workers (8->4) and lengthening the interval (0.25s->0.5s)
79+
# both lower it, to ~8 req/s, keeping the fan-out under GitHub's secondary (burst-rate) limit
80+
# that the prior 8-worker / 0.25s pool tripped.
81+
_FINGERPRINT_WORKERS = 4
82+
_FINGERPRINT_SECONDS_BETWEEN_REQUESTS = 0.5
7283

7384

7485
def _utcnow() -> datetime:
@@ -186,7 +197,7 @@ def run(
186197
requester: str | None = None,
187198
allow_untrusted_author: bool = False,
188199
bot_login: str = "",
189-
build_github: Callable[[str], Github] = github_client.build_client,
200+
build_github: _BuildClient = github_client.build_client,
190201
fetch: Callable[[Github], list[OpenPR]] = _default_fetch,
191202
fetch_author: Callable[[Github, int], str | None] = _default_fetch_author,
192203
fingerprint: FingerprintFn = _default_fingerprint,
@@ -260,7 +271,7 @@ def run(
260271
# and guarantees no two running tasks ever share one.
261272
client_pool: queue.Queue[Github] = queue.Queue()
262273
for _ in range(worker_count):
263-
worker_client = build_github(token)
274+
worker_client = build_github(token, seconds_between_requests=_FINGERPRINT_SECONDS_BETWEEN_REQUESTS)
264275
clients.callback(_close_client, worker_client)
265276
client_pool.put(worker_client)
266277
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: 33 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,19 @@ 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+
# The asserted quantity is the fan-out's aggregate request rate (workers / seconds-between-requests).
1258+
# 8 req/s is our own conservative budget, NOT a GitHub-published limit -- it is the ceiling we chose
1259+
# to stay comfortably under GitHub's (undocumented, variable) secondary/burst rate limit. It bounds
1260+
# only this fingerprint fan-out; the listing, dispatch, verdict, and authz clients each pace
1261+
# themselves independently and sit outside this budget.
1262+
assert review._FINGERPRINT_WORKERS / review._FINGERPRINT_SECONDS_BETWEEN_REQUESTS <= 8
12491263

12501264

12511265
def test_close_client_swallows_close_errors(caplog):
@@ -1302,7 +1316,7 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13021316
with caplog.at_level(logging.WARNING, logger="greenlight"), pytest.raises(RuntimeError) as excinfo:
13031317
review.run(
13041318
make_config(github_token="t"),
1305-
build_github=lambda _token: _CLIENT,
1319+
build_github=lambda _token, **_kwargs: _CLIENT,
13061320
fetch=lambda _client: [_open_pr(1), _open_pr(2), _open_pr(3)],
13071321
fingerprint=fingerprint,
13081322
read_state=lambda _repo, _numbers: {},
@@ -1344,7 +1358,7 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13441358
with caplog.at_level(logging.WARNING, logger="greenlight"), pytest.raises(RuntimeError) as excinfo:
13451359
review.run(
13461360
make_config(github_token="t"),
1347-
build_github=lambda _token: _CLIENT,
1361+
build_github=lambda _token, **_kwargs: _CLIENT,
13481362
fetch=lambda _client: [_open_pr(1), _open_pr(2), _open_pr(3)],
13491363
fingerprint=fingerprint,
13501364
read_state=lambda _repo, _numbers: {},
@@ -1433,7 +1447,7 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
14331447
review.run(
14341448
make_config(github_token="t"),
14351449
max_dispatches=5,
1436-
build_github=lambda _token: _CLIENT,
1450+
build_github=lambda _token, **_kwargs: _CLIENT,
14371451
fetch=lambda _client: [_open_pr(1), _open_pr(2), _open_pr(3)],
14381452
fingerprint=fingerprint,
14391453
read_state=lambda _repo, _numbers: {},
@@ -1491,7 +1505,7 @@ def boom_fetch(_client):
14911505
with pytest.raises(RuntimeError, match="fetch boom"):
14921506
review.run(
14931507
make_config(github_token="t"),
1494-
build_github=lambda _token: cast("Github", client),
1508+
build_github=lambda _token, **_kwargs: cast("Github", client),
14951509
fetch=boom_fetch,
14961510
fingerprint=lambda _client, number, _authorized, _skip: (f"headsha{number}", _HASH_A),
14971511
read_state=lambda _repo, _numbers: {},
@@ -1564,7 +1578,7 @@ def boom_resolve() -> frozenset[str]:
15641578
with pytest.raises(RuntimeError, match="merge_rules unreachable"):
15651579
review.run(
15661580
make_config(github_token="t"),
1567-
build_github=lambda _token: _CLIENT,
1581+
build_github=lambda _token, **_kwargs: _CLIENT,
15681582
fetch=lambda _client: [_open_pr(1)],
15691583
fingerprint=lambda _client, number, _authorized, _skip: (f"headsha{number}", _HASH_A),
15701584
read_state=lambda _repo, _numbers: {},

0 commit comments

Comments
 (0)