#!/usr/bin/env python3 """ Unit tests for http_utils.fetch's retry/backoff behavior. All network calls are mocked -- these never touch the real network. Run: python -m unittest discover -s .claude/skills/literature-search-verify/scripts """ import os import sys import unittest import urllib.error import urllib.request from email.message import Message from unittest import mock sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) import http_utils def _http_error(code, retry_after=None): hdrs = Message() if retry_after is not None: hdrs["Retry-After"] = str(retry_after) return urllib.error.HTTPError("http://example.com", code, "err", hdrs, None) class FakeResponse: def __init__(self, data): self._data = data def read(self): return self._data def __enter__(self): return self def __exit__(self, *exc_info): return False class TestFetch(unittest.TestCase): @mock.patch("urllib.request.urlopen") @mock.patch("http_utils.time.sleep", return_value=None) def test_succeeds_first_try_without_sleeping(self, mock_sleep, mock_urlopen): mock_urlopen.return_value = FakeResponse(b"ok") result = http_utils.fetch("http://example.com") self.assertEqual(result, b"ok") mock_sleep.assert_not_called() @mock.patch("urllib.request.urlopen") @mock.patch("http_utils.time.sleep", return_value=None) def test_retries_on_429_then_succeeds(self, mock_sleep, mock_urlopen): mock_urlopen.side_effect = [_http_error(429), FakeResponse(b"ok")] result = http_utils.fetch("http://example.com", max_retries=3) self.assertEqual(result, b"ok") self.assertEqual(mock_urlopen.call_count, 2) mock_sleep.assert_called_once() @mock.patch("urllib.request.urlopen") @mock.patch("http_utils.time.sleep", return_value=None) def test_retries_on_5xx(self, mock_sleep, mock_urlopen): mock_urlopen.side_effect = [_http_error(503), FakeResponse(b"ok")] result = http_utils.fetch("http://example.com", max_retries=3) self.assertEqual(result, b"ok") @mock.patch("urllib.request.urlopen") @mock.patch("http_utils.time.sleep", return_value=None) def test_raises_after_exhausting_retries(self, mock_sleep, mock_urlopen): mock_urlopen.side_effect = [_http_error(429)] * 10 with self.assertRaises(urllib.error.HTTPError): http_utils.fetch("http://example.com", max_retries=2) self.assertEqual(mock_urlopen.call_count, 3) # initial attempt + 2 retries @mock.patch("urllib.request.urlopen") @mock.patch("http_utils.time.sleep", return_value=None) def test_non_retryable_status_raises_immediately(self, mock_sleep, mock_urlopen): mock_urlopen.side_effect = _http_error(404) with self.assertRaises(urllib.error.HTTPError): http_utils.fetch("http://example.com") self.assertEqual(mock_urlopen.call_count, 1) mock_sleep.assert_not_called() class TestWaitSeconds(unittest.TestCase): def test_honors_retry_after_header(self): err = _http_error(429, retry_after=5) self.assertEqual(http_utils._wait_seconds(err, attempt=0), 5.0) def test_falls_back_to_exponential_backoff_without_header(self): err = _http_error(429) self.assertAlmostEqual(http_utils._wait_seconds(err, attempt=2, base=1.0), 4.0) def test_caps_wait_time(self): err = _http_error(429, retry_after=999) self.assertEqual(http_utils._wait_seconds(err, attempt=0, cap=20.0), 20.0) def test_ignores_unparseable_retry_after(self): err = _http_error(429, retry_after="not-a-number") self.assertAlmostEqual(http_utils._wait_seconds(err, attempt=0, base=1.0), 1.0) if __name__ == "__main__": unittest.main()