feat: task 9 — add _graph_request helper with retry on 429/503
Add module-level _graph_request() that wraps requests.request() with: - Up to 3 retries on HTTP 429 (rate limited) and 503 (unavailable) - Exponential backoff capped at 60 s, honouring Retry-After header - Default timeout=30 s injected via setdefault (caller can override) Wire all 13 retry-eligible API calls through _graph_request(). The 3 file-upload requests.put(data=f) calls are kept direct since an open stream cannot be re-read after the first attempt. Add 9 unit tests covering: success path, 429/503 retry, Retry-After header, max-retry exhaustion, timeout injection and override. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -305,12 +305,17 @@ class TestNetworkTimeouts(unittest.TestCase):
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"sharepoint_browser.py"
|
||||
)
|
||||
# Matches real call sites: assignment or standalone call (not docstrings)
|
||||
pattern = re.compile(
|
||||
r'requests\.(get|post|put|patch|delete)\('
|
||||
r'(=\s*|^\s*)requests\.(get|post|put|patch|delete)\('
|
||||
)
|
||||
missing = []
|
||||
with open(src_path, encoding='utf-8') as fh:
|
||||
for lineno, line in enumerate(fh, 1):
|
||||
stripped = line.lstrip()
|
||||
# Skip comment lines and docstring prose (lines that are plain text)
|
||||
if stripped.startswith('#'):
|
||||
continue
|
||||
if pattern.search(line) and 'timeout=' not in line:
|
||||
missing.append((lineno, line.rstrip()))
|
||||
return missing
|
||||
@@ -325,5 +330,97 @@ class TestNetworkTimeouts(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 9: _graph_request helper — retry on 429/503 with Retry-After support
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGraphRequest(unittest.TestCase):
|
||||
"""Unit tests for the _graph_request() module-level helper."""
|
||||
|
||||
def test_helper_exists(self):
|
||||
"""Task 9: _graph_request function is defined at module level."""
|
||||
self.assertTrue(
|
||||
callable(getattr(sb, "_graph_request", None)),
|
||||
"_graph_request is not defined in sharepoint_browser"
|
||||
)
|
||||
|
||||
def test_success_on_first_attempt(self):
|
||||
"""Task 9: Returns immediately when first response is 200."""
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
with patch("sharepoint_browser.requests.request", return_value=mock_resp) as mock_req:
|
||||
result = sb._graph_request("GET", "https://example.com/", headers={})
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(mock_req.call_count, 1)
|
||||
|
||||
def test_retries_on_429(self):
|
||||
"""Task 9: Retries when response is 429 (rate limited)."""
|
||||
responses = [
|
||||
MagicMock(status_code=429, headers={"Retry-After": "0"}),
|
||||
MagicMock(status_code=429, headers={"Retry-After": "0"}),
|
||||
MagicMock(status_code=200, headers={}),
|
||||
]
|
||||
with patch("sharepoint_browser.requests.request", side_effect=responses) as mock_req:
|
||||
with patch("sharepoint_browser.time.sleep"):
|
||||
result = sb._graph_request("GET", "https://example.com/", headers={})
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(mock_req.call_count, 3)
|
||||
|
||||
def test_retries_on_503(self):
|
||||
"""Task 9: Retries when response is 503 (service unavailable)."""
|
||||
responses = [
|
||||
MagicMock(status_code=503, headers={}),
|
||||
MagicMock(status_code=200, headers={}),
|
||||
]
|
||||
with patch("sharepoint_browser.requests.request", side_effect=responses) as mock_req:
|
||||
with patch("sharepoint_browser.time.sleep"):
|
||||
result = sb._graph_request("POST", "https://example.com/", headers={})
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(mock_req.call_count, 2)
|
||||
|
||||
def test_returns_last_response_after_max_retries(self):
|
||||
"""Task 9: Returns last 429 response when all retries are exhausted."""
|
||||
resp_429 = MagicMock(status_code=429, headers={"Retry-After": "0"})
|
||||
responses = [resp_429] * sb._MAX_RETRIES
|
||||
with patch("sharepoint_browser.requests.request", side_effect=responses):
|
||||
with patch("sharepoint_browser.time.sleep"):
|
||||
result = sb._graph_request("GET", "https://example.com/", headers={})
|
||||
self.assertEqual(result.status_code, 429)
|
||||
|
||||
def test_respects_retry_after_header(self):
|
||||
"""Task 9: sleep() is called with the Retry-After value from the response."""
|
||||
responses = [
|
||||
MagicMock(status_code=429, headers={"Retry-After": "5"}),
|
||||
MagicMock(status_code=200, headers={}),
|
||||
]
|
||||
with patch("sharepoint_browser.requests.request", side_effect=responses):
|
||||
with patch("sharepoint_browser.time.sleep") as mock_sleep:
|
||||
sb._graph_request("GET", "https://example.com/", headers={})
|
||||
mock_sleep.assert_called_once_with(5)
|
||||
|
||||
def test_default_timeout_injected(self):
|
||||
"""Task 9: timeout=30 is injected when caller does not provide one."""
|
||||
mock_resp = MagicMock(status_code=200, headers={})
|
||||
with patch("sharepoint_browser.requests.request", return_value=mock_resp) as mock_req:
|
||||
sb._graph_request("GET", "https://example.com/", headers={})
|
||||
_, kwargs = mock_req.call_args
|
||||
self.assertEqual(kwargs.get("timeout"), 30)
|
||||
|
||||
def test_caller_timeout_not_overridden(self):
|
||||
"""Task 9: Explicit timeout from caller is not overwritten by the helper."""
|
||||
mock_resp = MagicMock(status_code=200, headers={})
|
||||
with patch("sharepoint_browser.requests.request", return_value=mock_resp) as mock_req:
|
||||
sb._graph_request("GET", "https://example.com/", headers={}, timeout=60)
|
||||
_, kwargs = mock_req.call_args
|
||||
self.assertEqual(kwargs.get("timeout"), 60)
|
||||
|
||||
def test_max_retries_constant_exists(self):
|
||||
"""Task 9: _MAX_RETRIES constant is defined."""
|
||||
self.assertTrue(
|
||||
hasattr(sb, "_MAX_RETRIES"),
|
||||
"_MAX_RETRIES constant not found in sharepoint_browser"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
Reference in New Issue
Block a user