Add a retrying HTTP helper shared by every remote call

One place decides what is worth another attempt: 429, 5xx and network
errors, with jittered exponential backoff and Retry-After honoured. Other
4xx responses fail at once. Downloads stream to disk under the same policy,
refuse a body over the size cap, and only flickr calls and photo downloads
are paced; the api key is redacted from debug output.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bobandClaude Fable 5.1 committed 2026-09-10 12:07:44 +01:00
1 parent b916165cab
commit f05837bd5e
2 files changed
+173

No files matched your search

+67
View File
@@ -236,3 +236,70 @@ class Settings(TestCase):
def test_tilde_expanded_in_paths(self):
path = self.write("s.yml", "directory: ~/photos\n")
self.assertEqual(rb.loadsettings(path)["directory"], os.path.expanduser("~/photos"))
class Retrying(TestCase):
def test_success_returns_body(self):
rb.OPEN = FakeOpen(FakeResponse(b"ok"))
self.assertEqual(rb.fetch("http://t/x", "ctx"), b"ok")
self.assertEqual(rb.OPEN.requests[0].get_header("User-agent"), rb.USER_AGENT)
def test_404_is_not_retried(self):
rb.OPEN = FakeOpen(http_error(404, b"gone"))
with self.assertRaises(rb.HTTPFailure) as cm:
rb.fetch("http://t/x", "ctx")
self.assertEqual(cm.exception.code, 404)
self.assertIn("gone", str(cm.exception))
self.assertEqual(len(rb.OPEN.requests), 1)
def test_5xx_and_429_retry_then_succeed(self):
rb.OPEN = FakeOpen(http_error(503), http_error(429), FakeResponse(b"ok"))
self.assertEqual(rb.fetch("http://t/x", "ctx"), b"ok")
self.assertEqual(len(rb.OPEN.requests), 3)
self.assertEqual(rb.STATS["retries"], 2)
def test_connection_errors_retry(self):
rb.OPEN = FakeOpen(OSError("reset"), FakeResponse(b"ok"))
self.assertEqual(rb.fetch("http://t/x", "ctx"), b"ok")
def test_gives_up_after_attempts(self):
rb.ATTEMPTS = 3
rb.OPEN = FakeOpen(http_error(500), http_error(500), http_error(500))
with self.assertRaises(rb.HTTPFailure) as cm:
rb.fetch("http://t/x", "ctx")
self.assertIn("3 attempts", str(cm.exception))
def test_retry_after_header_is_honoured(self):
waits = []
rb.SLEEP = waits.append
rb.OPEN = FakeOpen(http_error(429, headers={"Retry-After": "12"}), FakeResponse(b"ok"))
rb.fetch("http://t/x", "ctx")
self.assertIn(12.0, waits)
def test_backoff_grows_and_caps(self):
rb.BACKOFF_BASE = 5
self.assertLessEqual(rb.backoff(1), 7.5)
self.assertGreaterEqual(rb.backoff(1), 2.5)
self.assertLessEqual(rb.backoff(20), rb.BACKOFF_CAP * 1.5)
def test_post_sends_body_and_headers(self):
rb.OPEN = FakeOpen(FakeResponse(b"{}"))
rb.fetch("http://t/x", "ctx", headers={"x-api-key": "k"}, data=b"body", method="POST")
req = rb.OPEN.requests[0]
self.assertEqual(req.get_method(), "POST")
self.assertEqual(req.data, b"body")
self.assertEqual(req.get_header("X-api-key"), "k")
def test_download_streams_to_path(self):
rb.OPEN = FakeOpen(FakeResponse(b"x" * 1000, headers={"Content-Type": "image/jpeg"}))
path = os.path.join(self.dir, "p.part")
self.assertEqual(rb.download("http://t/p.jpg", "ctx", path), ("image/jpeg", 1000))
self.assertEqual(os.path.getsize(path), 1000)
def test_download_refuses_an_oversized_body(self):
rb.DOWNLOAD_CAP = 10
rb.OPEN = FakeOpen(FakeResponse(b"x" * 100))
path = os.path.join(self.dir, "p.part")
with self.assertRaises(rb.BackupError) as cm:
rb.download("http://t/p.jpg", "ctx", path)
self.assertIn("10", str(cm.exception))
self.assertEqual(len(rb.OPEN.requests), 1) # not a retryable failure