Skip to content

Commit 61df625

Browse files
authored
Bound the GitHub client retries so rate limits fail fast (#8506)
Stack from [ghstack](https://github.com/ezyang/ghstack/tree/0.14.0) (oldest at bottom): * #8568 * #8508 * #8507 * __->__ #8506 * #8505 **Impact:** greenlight scan, fingerprint, and verdict paths (the GitHub client) **Risk:** low ## What Wire a bounded plain `urllib3` `Retry` into `build_client`: 5xx-only forcelist (403/429 excluded), `respect_retry_after_header=False`, idempotent methods only (`GET`/`HEAD`/`PUT`/`DELETE`), `total=2`. ## Why PyGithub's default `GithubRetry` force-lists 403 and sleeps in-call until the rate-limit reset (up to ~1h, unclampable by config), on fingerprint worker threads the main-thread SIGALRM timeout cannot interrupt -- so a single scan iteration could stall past the runtime budget and be force-killed (daemon watchdog / Lambda timeout). A 5xx-only retry that never honors `Retry-After` lets a rate limit raise immediately, so the pass is abandoned through the existing exception flow instead of hanging. # Notes Not retrying `POST`/`PATCH` also removes a latent double-review / double-dispatch hazard from the old default. Retry semantics are asserted in new `test_github_client.py` cases, including that the type is a plain `urllib3.Retry` (not `GithubRetry`) and that 403/429 are excluded from the forcelist. Signed-off-by: Jean Schmidt <contato@jschmidt.me>
1 parent c5127d1 commit 61df625

2 files changed

Lines changed: 70 additions & 1 deletion

File tree

greenlight/src/greenlight/github_client.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from datetime import datetime
2121

2222
from github import Github
23+
from urllib3.util.retry import Retry
2324

2425
from greenlight.github_types import (
2526
ScanClient,
@@ -49,11 +50,38 @@ class OpenPR:
4950
# worst-case pagination outlast the per-iteration runtime watchdog.
5051
_GITHUB_TIMEOUT_SECONDS: int = 15
5152

53+
_GITHUB_RETRY_TOTAL: int = 2
54+
_GITHUB_RETRY_BACKOFF_FACTOR: float = 0.5
55+
_GITHUB_RETRY_BACKOFF_MAX_SECONDS: float = 5.0
56+
57+
58+
def _build_retry() -> Retry:
59+
# Not PyGithub's default GithubRetry: it force-lists 403 and sleeps in-call until the
60+
# rate-limit reset, which would stall a fingerprint worker past the per-iteration runtime
61+
# budget. A plain urllib3 Retry with a 5xx-only forcelist lets a rate limit raise at once.
62+
from urllib3.util.retry import Retry
63+
64+
return Retry(
65+
total=_GITHUB_RETRY_TOTAL,
66+
backoff_factor=_GITHUB_RETRY_BACKOFF_FACTOR,
67+
backoff_max=_GITHUB_RETRY_BACKOFF_MAX_SECONDS,
68+
status_forcelist=frozenset(range(500, 600)),
69+
allowed_methods=frozenset({"GET", "HEAD", "PUT", "DELETE"}),
70+
respect_retry_after_header=False,
71+
raise_on_status=True,
72+
)
73+
5274

5375
def build_client(token: str) -> Github:
5476
from github import Auth, Github # lazy: keeps this module importable without the dep
5577

56-
return Github(auth=Auth.Token(token), per_page=100, timeout=_GITHUB_TIMEOUT_SECONDS, lazy=True)
78+
return Github(
79+
auth=Auth.Token(token),
80+
per_page=100,
81+
timeout=_GITHUB_TIMEOUT_SECONDS,
82+
retry=_build_retry(),
83+
lazy=True,
84+
)
5785

5886

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

greenlight/tests/test_github_client.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import pytest
44
from github import Github
5+
from urllib3.util.retry import Retry
56

67
from greenlight import github_client
78
from greenlight.constants import EVAL_HASH_RE
@@ -365,6 +366,46 @@ def test_build_client_pins_request_timeout():
365366
assert requester.__dict__["_Requester__timeout"] == 15
366367

367368

369+
def test_build_retry_is_bounded_and_omits_rate_limit_statuses():
370+
retry = github_client._build_retry()
371+
372+
# GithubRetry subclasses urllib3.Retry, so assert the exact type -- isinstance would
373+
# pass for the GithubRetry we're rejecting.
374+
assert type(retry) is Retry
375+
assert 403 not in retry.status_forcelist
376+
assert 429 not in retry.status_forcelist
377+
assert 500 in retry.status_forcelist
378+
assert 503 in retry.status_forcelist
379+
assert retry.respect_retry_after_header is False
380+
assert retry.allowed_methods == frozenset({"GET", "HEAD", "PUT", "DELETE"})
381+
assert "PUT" in retry.allowed_methods
382+
assert "DELETE" in retry.allowed_methods
383+
assert "POST" not in retry.allowed_methods
384+
assert "PATCH" not in retry.allowed_methods
385+
assert retry.total == 2
386+
assert retry.backoff_factor == 0.5
387+
assert retry.backoff_max == 5.0
388+
389+
390+
def test_build_client_wires_bounded_retry_into_github(monkeypatch):
391+
captured: dict[str, object] = {}
392+
393+
def _fake_github(**kwargs: object) -> object:
394+
captured.update(kwargs)
395+
return object()
396+
397+
# build_client re-runs `from github import ... Github` per call, so patching the attribute on
398+
# the github module is picked up; Auth is left real (Auth.Token is a pure, offline wrapper).
399+
monkeypatch.setattr("github.Github", _fake_github)
400+
401+
github_client.build_client("tok")
402+
403+
retry = captured["retry"]
404+
assert isinstance(retry, Retry)
405+
assert 403 not in retry.status_forcelist
406+
assert retry.respect_retry_after_header is False
407+
408+
368409
def _build_fp(
369410
pr: _FakePR,
370411
*,

0 commit comments

Comments
 (0)