Skip to content

Commit a484a46

Browse files
ryan-williamsclaude
andcommitted
ci: retry transient GH-API and NJSP-feed failures
Two daily-run failures this week: - 6/26: NJSP feed returned `403 Forbidden` for `FAUQStats2024.xml` (transient upstream blip; 6/27 refresh against same URL succeeded). - 6/27: GH API `429 "This endpoint is temporarily being throttled"` while walking parent commits in `crash-log.parquet` compute. With shallow CI clone (`actions/checkout` default `fetch-depth: 1`), `get_crash_log` falls through to `GithubCommit.parent` → `gh.get_commit` per step, which trips GitHub's secondary rate limit (PyGithub auto-retries primary, not secondary). `nj_crashes/utils/retry`: - `http_get_with_retry`: capped exp-backoff on connection errors and on `{403, 408, 429, 500, 502, 503, 504}`. Honors `Retry-After` (seconds or HTTP-date). 404 passes through (caller's current-year carve-out kept). - `with_gh_retry`: decorator that retries `GithubException.status` in `{429, 502, 503, 504}` and honors the exception's `Retry-After` header. Wire-in: - `njsp/cli/refresh_data.update_years`: route the FAUQStats GET through `http_get_with_retry`. HEAD stays bare (already non-fatal). - `nj_crashes/utils/github`: route all `gh.get_commit` / `gh.get_git_tree` / `gh.get_git_blob` / `repo.get_contents` calls through `with_gh_retry`- wrapped module-level helpers. 13 unit tests cover both helpers (status filtering, Retry-After honoring, attempt exhaustion, connection-error path). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 66a5e7f commit a484a46

4 files changed

Lines changed: 295 additions & 22 deletions

File tree

nj_crashes/tests/test_retry.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
import pytest
4+
import requests
5+
from github import GithubException
6+
7+
from nj_crashes.utils.retry import (
8+
_parse_retry_after,
9+
http_get_with_retry,
10+
with_gh_retry,
11+
)
12+
13+
14+
def test_parse_retry_after_seconds():
15+
assert _parse_retry_after("5") == 5.0
16+
assert _parse_retry_after("0") == 0.0
17+
18+
19+
def test_parse_retry_after_none_or_bad():
20+
assert _parse_retry_after(None) is None
21+
assert _parse_retry_after("") is None
22+
assert _parse_retry_after("garbage") is None
23+
24+
25+
def test_parse_retry_after_http_date_clamped_nonnegative():
26+
# Past dates should clamp to 0 (already past); future dates positive.
27+
past = _parse_retry_after("Wed, 01 Jan 2020 00:00:00 GMT")
28+
assert past == 0.0
29+
future = _parse_retry_after("Wed, 01 Jan 2099 00:00:00 GMT")
30+
assert future is not None and future > 0
31+
32+
33+
def _resp(status_code: int, headers: dict | None = None, content: bytes = b"ok"):
34+
r = MagicMock(spec=requests.Response)
35+
r.status_code = status_code
36+
r.reason = "Reason"
37+
r.headers = headers or {}
38+
r.content = content
39+
return r
40+
41+
42+
def test_http_get_returns_immediately_on_200():
43+
with patch("nj_crashes.utils.retry.requests.get") as g, \
44+
patch("nj_crashes.utils.retry.time.sleep") as sleep:
45+
g.return_value = _resp(200)
46+
res = http_get_with_retry("http://x", max_attempts=3)
47+
assert res.status_code == 200
48+
assert g.call_count == 1
49+
sleep.assert_not_called()
50+
51+
52+
def test_http_get_retries_on_403_then_succeeds():
53+
# NJSP 6/26 failure: transient 403, recovers on retry.
54+
with patch("nj_crashes.utils.retry.requests.get") as g, \
55+
patch("nj_crashes.utils.retry.time.sleep") as sleep:
56+
g.side_effect = [_resp(403), _resp(200)]
57+
res = http_get_with_retry("http://x", max_attempts=3, base=0.01, cap=0.01)
58+
assert res.status_code == 200
59+
assert g.call_count == 2
60+
assert sleep.call_count == 1
61+
62+
63+
def test_http_get_honors_retry_after():
64+
with patch("nj_crashes.utils.retry.requests.get") as g, \
65+
patch("nj_crashes.utils.retry.time.sleep") as sleep:
66+
g.side_effect = [_resp(429, headers={"Retry-After": "7"}), _resp(200)]
67+
http_get_with_retry("http://x", max_attempts=3, base=0.01)
68+
sleep.assert_called_once_with(7.0)
69+
70+
71+
def test_http_get_exhausts_attempts_and_returns_last_response():
72+
with patch("nj_crashes.utils.retry.requests.get") as g, \
73+
patch("nj_crashes.utils.retry.time.sleep"):
74+
g.return_value = _resp(503)
75+
res = http_get_with_retry("http://x", max_attempts=3, base=0.01)
76+
assert res.status_code == 503
77+
assert g.call_count == 3
78+
79+
80+
def test_http_get_passes_404_through_without_retry():
81+
# 404 not in default retry_statuses — should return on first attempt.
82+
with patch("nj_crashes.utils.retry.requests.get") as g, \
83+
patch("nj_crashes.utils.retry.time.sleep") as sleep:
84+
g.return_value = _resp(404)
85+
res = http_get_with_retry("http://x", max_attempts=3)
86+
assert res.status_code == 404
87+
assert g.call_count == 1
88+
sleep.assert_not_called()
89+
90+
91+
def test_http_get_retries_on_connection_error():
92+
with patch("nj_crashes.utils.retry.requests.get") as g, \
93+
patch("nj_crashes.utils.retry.time.sleep") as sleep:
94+
g.side_effect = [requests.ConnectionError("nope"), _resp(200)]
95+
res = http_get_with_retry("http://x", max_attempts=3, base=0.01)
96+
assert res.status_code == 200
97+
assert sleep.call_count == 1
98+
99+
100+
def test_gh_retry_returns_immediately_on_success():
101+
calls = []
102+
103+
@with_gh_retry(max_attempts=3, base=0.01, cap=0.01)
104+
def fn():
105+
calls.append(1)
106+
return "ok"
107+
108+
assert fn() == "ok"
109+
assert calls == [1]
110+
111+
112+
def test_gh_retry_429_then_success():
113+
seq = [
114+
GithubException(429, {"message": "throttled"}, {"Retry-After": "0"}),
115+
"ok",
116+
]
117+
calls = []
118+
119+
@with_gh_retry(max_attempts=3, base=0.01, cap=0.01)
120+
def fn():
121+
calls.append(1)
122+
v = seq.pop(0)
123+
if isinstance(v, Exception):
124+
raise v
125+
return v
126+
127+
with patch("nj_crashes.utils.retry.time.sleep") as sleep:
128+
assert fn() == "ok"
129+
assert len(calls) == 2
130+
sleep.assert_called_once_with(0.0)
131+
132+
133+
def test_gh_retry_non_retryable_status_raises_immediately():
134+
@with_gh_retry(max_attempts=3, base=0.01, cap=0.01)
135+
def fn():
136+
raise GithubException(404, {"message": "not found"}, {})
137+
138+
with pytest.raises(GithubException) as ei:
139+
fn()
140+
assert ei.value.status == 404
141+
142+
143+
def test_gh_retry_exhausts_attempts_and_raises_last():
144+
@with_gh_retry(max_attempts=3, base=0.01, cap=0.01)
145+
def fn():
146+
raise GithubException(429, {"message": "throttled"}, {})
147+
148+
with patch("nj_crashes.utils.retry.time.sleep"):
149+
with pytest.raises(GithubException) as ei:
150+
fn()
151+
assert ei.value.status == 429

nj_crashes/utils/github.py

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,42 @@
1818
from utz import proc
1919

2020
from nj_crashes.utils.git import git_fmt
21+
from nj_crashes.utils.retry import with_gh_retry
2122
from njdot.rawdata.utils import singleton
2223

2324
REPO = 'hudcostreets/nj-crashes'
2425
_gh: Github | None = None
2526
_gh_repo: Repository | None = None
2627

2728

29+
@with_gh_retry()
30+
def _gh_get_commit(sha: str) -> Commit:
31+
return get_github_repo().get_commit(sha)
32+
33+
34+
@with_gh_retry()
35+
def _gh_get_git_tree(sha: str) -> GitTree:
36+
return get_github_repo().get_git_tree(sha)
37+
38+
39+
@with_gh_retry()
40+
def _gh_get_git_blob(hexsha: str):
41+
return get_github_repo().get_git_blob(hexsha)
42+
43+
44+
@with_gh_retry()
45+
def _gh_get_contents(path: str, ref: str | None = None):
46+
return get_github_repo().get_contents(path, ref=ref)
47+
48+
2849
def expand_ref(ref: str) -> str:
2950
if fullmatch(r'^[0-9a-f]{40}$', ref):
3051
return ref
3152
else:
3253
try:
3354
return git_fmt(ref, fmt='%H')
3455
except CalledProcessError:
35-
gh = get_github_repo()
36-
return gh.get_commit(ref).sha
56+
return _gh_get_commit(ref).sha
3757

3858

3959
def expand_refspec(refspec: str, *args: str) -> list[str]:
@@ -76,9 +96,9 @@ def load_github(
7696
ref: str | None = None,
7797
repo: Repository | None = None,
7898
) -> bytes:
79-
if repo is None:
80-
repo = get_github_repo()
81-
return repo.get_contents(path, ref=ref).decoded_content
99+
if repo is not None:
100+
return repo.get_contents(path, ref=ref).decoded_content
101+
return _gh_get_contents(path, ref=ref).decoded_content
82102

83103

84104
def load_pqt_github(
@@ -98,8 +118,7 @@ class GithubBlob:
98118

99119
@property
100120
def data_stream(self):
101-
gh = get_github_repo()
102-
blob = gh.get_git_blob(self.hexsha)
121+
blob = _gh_get_git_blob(self.hexsha)
103122
return BytesIO(b64decode(blob.content))
104123
# return BytesIO(process.output('gh', 'api', '-H', 'Accept: application/vnd.github.raw+json', f'/repos/{REPO}/git/blobs/{self.hexsha}'))
105124
# TODO: streaming response
@@ -118,14 +137,12 @@ class GithubTree:
118137

119138
@staticmethod
120139
def from_commit(commit: Commit) -> 'GithubTree':
121-
gh = get_github_repo()
122-
tree = gh.get_git_tree(commit.raw_data['commit']['tree']['sha'])
140+
tree = _gh_get_git_tree(commit.raw_data['commit']['tree']['sha'])
123141
return GithubTree(tree)
124142

125143
@staticmethod
126144
def from_sha(sha: str) -> 'GithubTree':
127-
gh = get_github_repo()
128-
return GithubTree(gh.get_git_tree(sha))
145+
return GithubTree(_gh_get_git_tree(sha))
129146

130147
@cached_property
131148
def raw_data(self):
@@ -171,9 +188,8 @@ def blobs(self):
171188

172189
@property
173190
def trees(self):
174-
gh = get_github_repo()
175191
return [
176-
GithubTree(gh.get_git_tree(e['sha']))
192+
GithubTree(_gh_get_git_tree(e['sha']))
177193
for e in self.children if e['type'] == 'tree'
178194
]
179195

@@ -187,13 +203,11 @@ class GithubCommit:
187203

188204
@staticmethod
189205
def from_git(git_commit: git.Commit) -> 'GithubCommit':
190-
gh = get_github_repo()
191-
return GithubCommit(gh.get_commit(git_commit.hexsha))
206+
return GithubCommit(_gh_get_commit(git_commit.hexsha))
192207

193208
@staticmethod
194209
def from_sha(sha: str) -> 'GithubCommit':
195-
gh = get_github_repo()
196-
return GithubCommit(gh.get_commit(sha))
210+
return GithubCommit(_gh_get_commit(sha))
197211

198212
@property
199213
def raw_data(self):
@@ -213,9 +227,8 @@ def tree_sha(self) -> str:
213227

214228
@property
215229
def parents(self) -> list['GithubCommit']:
216-
gh = get_github_repo()
217230
return [
218-
GithubCommit(gh.get_commit(p['sha']))
231+
GithubCommit(_gh_get_commit(p['sha']))
219232
for p in self.raw_data['parents']
220233
]
221234

@@ -229,8 +242,7 @@ def parent(self) -> 'GithubCommit':
229242

230243
@cached_property
231244
def tree(self) -> GithubTree:
232-
gh = get_github_repo()
233-
tree = gh.get_git_tree(self.tree_sha)
245+
tree = _gh_get_git_tree(self.tree_sha)
234246
return GithubTree(tree)
235247

236248

nj_crashes/utils/retry.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Retry helpers for HTTP fetches and PyGithub API calls.
2+
3+
Both wrappers honor a `Retry-After` header when present (in seconds or
4+
HTTP-date form), and fall back to capped exponential backoff with jitter.
5+
6+
GitHub's "secondary" rate limit returns 429 with a textual "temporarily being
7+
throttled" message; PyGithub auto-retries the primary (X-RateLimit-Remaining)
8+
limit but not this one. `with_gh_retry` covers the gap. `http_get_with_retry`
9+
covers transient upstream failures (the NJSP feed has thrown 403/5xx).
10+
"""
11+
from __future__ import annotations
12+
13+
import random
14+
import time
15+
from email.utils import parsedate_to_datetime
16+
from datetime import datetime, timezone
17+
from functools import wraps
18+
19+
import requests
20+
from github import GithubException
21+
22+
from .log import err
23+
24+
25+
GH_RETRY_STATUSES = frozenset({429, 502, 503, 504})
26+
HTTP_RETRY_STATUSES = frozenset({403, 408, 429, 500, 502, 503, 504})
27+
28+
29+
def _parse_retry_after(value: str | None) -> float | None:
30+
"""Parse a Retry-After header value (seconds-int or HTTP-date)."""
31+
if not value:
32+
return None
33+
try:
34+
return float(int(value))
35+
except (TypeError, ValueError):
36+
pass
37+
try:
38+
when = parsedate_to_datetime(value)
39+
except (TypeError, ValueError):
40+
return None
41+
if when is None:
42+
return None
43+
if when.tzinfo is None:
44+
when = when.replace(tzinfo=timezone.utc)
45+
return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
46+
47+
48+
def _backoff_seconds(attempt: int, base: float = 5.0, cap: float = 60.0) -> float:
49+
"""Exponential backoff with jitter, capped at `cap`."""
50+
return min(cap, base * (2 ** attempt)) + random.uniform(0, base / 2)
51+
52+
53+
def with_gh_retry(max_attempts: int = 5, base: float = 5.0, cap: float = 60.0):
54+
"""Decorator: retry the wrapped fn on transient GitHub API failures."""
55+
def deco(fn):
56+
@wraps(fn)
57+
def wrapper(*args, **kwargs):
58+
for attempt in range(max_attempts):
59+
try:
60+
return fn(*args, **kwargs)
61+
except GithubException as e:
62+
if e.status not in GH_RETRY_STATUSES or attempt == max_attempts - 1:
63+
raise
64+
headers = getattr(e, 'headers', None) or {}
65+
retry_after = _parse_retry_after(
66+
headers.get('retry-after') or headers.get('Retry-After')
67+
)
68+
sleep_s = retry_after if retry_after is not None else _backoff_seconds(attempt, base, cap)
69+
err(f"GH API {e.status}; sleeping {sleep_s:.1f}s (attempt {attempt + 1}/{max_attempts})")
70+
time.sleep(sleep_s)
71+
return wrapper
72+
return deco
73+
74+
75+
def http_get_with_retry(
76+
url: str,
77+
*,
78+
headers: dict | None = None,
79+
timeout: float = 30,
80+
max_attempts: int = 4,
81+
base: float = 3.0,
82+
cap: float = 30.0,
83+
retry_statuses: frozenset[int] = HTTP_RETRY_STATUSES,
84+
) -> requests.Response:
85+
"""GET `url` with retry on transient failures (connection errors + listed
86+
statuses, including 403 — observed transiently from the NJSP feed).
87+
88+
Returns the final `Response` (which may still be non-200 after exhausting
89+
attempts — the caller decides whether to raise).
90+
"""
91+
last_res = None
92+
for attempt in range(max_attempts):
93+
try:
94+
res = requests.get(url, allow_redirects=True, timeout=timeout, headers=headers)
95+
except requests.RequestException as e:
96+
if attempt == max_attempts - 1:
97+
raise
98+
sleep_s = _backoff_seconds(attempt, base, cap)
99+
err(f"GET {url}: {e}; sleeping {sleep_s:.1f}s (attempt {attempt + 1}/{max_attempts})")
100+
time.sleep(sleep_s)
101+
continue
102+
last_res = res
103+
if res.status_code not in retry_statuses or attempt == max_attempts - 1:
104+
return res
105+
retry_after = _parse_retry_after(res.headers.get('Retry-After'))
106+
sleep_s = retry_after if retry_after is not None else _backoff_seconds(attempt, base, cap)
107+
err(f"GET {url}: {res.status_code} {res.reason}; sleeping {sleep_s:.1f}s (attempt {attempt + 1}/{max_attempts})")
108+
time.sleep(sleep_s)
109+
return last_res

0 commit comments

Comments
 (0)