diff --git a/static_site.py b/static_site.py index e8f1680..46efbae 100755 --- a/static_site.py +++ b/static_site.py @@ -11,6 +11,7 @@ import os import subprocess import sys import tarfile +import time import urllib.error import urllib.parse import urllib.request @@ -21,6 +22,8 @@ from typing import Any MANIFEST_NAME = ".static-site-manifest.json" MANIFEST_VERSION = 1 +BUNNY_RETRY_DELAYS = (1, 2, 4, 8, 16) +BUNNY_TRANSIENT_STATUS_CODES = {401, 408, 429, 500, 502, 503, 504} class DeployError(RuntimeError): @@ -113,24 +116,34 @@ class BunnyStorage: ) -> bytes | None: url_path = urllib.parse.quote(path, safe="/") request_headers = {"AccessKey": self.password, **(headers or {})} - request = urllib.request.Request( - f"{self.base_url}/{url_path}", - data=body, - headers=request_headers, - method=method, - ) - try: - with urllib.request.urlopen(request, timeout=60) as response: - return response.read() - except urllib.error.HTTPError as exc: - if missing_ok and exc.code == 404: - return None - detail = exc.read().decode("utf-8", errors="replace") - raise DeployError( - f"Bunny Storage {method} {path} failed: HTTP {exc.code}: {detail}" - ) from exc - except urllib.error.URLError as exc: - raise DeployError(f"Bunny Storage {method} {path} failed: {exc.reason}") from exc + for attempt in range(len(BUNNY_RETRY_DELAYS) + 1): + request = urllib.request.Request( + f"{self.base_url}/{url_path}", + data=body, + headers=request_headers, + method=method, + ) + try: + with urllib.request.urlopen(request, timeout=60) as response: + return response.read() + except urllib.error.HTTPError as exc: + if missing_ok and exc.code == 404: + return None + detail = exc.read().decode("utf-8", errors="replace") + if ( + exc.code in BUNNY_TRANSIENT_STATUS_CODES + and attempt < len(BUNNY_RETRY_DELAYS) + ): + time.sleep(BUNNY_RETRY_DELAYS[attempt]) + continue + raise DeployError( + f"Bunny Storage {method} {path} failed: HTTP {exc.code}: {detail}" + ) from exc + except urllib.error.URLError as exc: + raise DeployError( + f"Bunny Storage {method} {path} failed: {exc.reason}" + ) from exc + raise AssertionError("unreachable Bunny Storage retry state") def get_manifest(self) -> bytes | None: return self._request("GET", MANIFEST_NAME, missing_ok=True) diff --git a/tests/test_static_site.py b/tests/test_static_site.py index 64f1a39..355e627 100644 --- a/tests/test_static_site.py +++ b/tests/test_static_site.py @@ -1,4 +1,5 @@ import importlib.util +from io import BytesIO import json from pathlib import Path import sys @@ -91,6 +92,29 @@ class StaticSiteTests(unittest.TestCase): with self.assertRaisesRegex(DeployError, "HTTPS origin"): purge_bunny_gateway(endpoint, "example-org", "password") + @mock.patch.object(static_site.time, "sleep") + @mock.patch.object(static_site.urllib.request, "urlopen") + def test_storage_retries_transient_new_zone_authentication( + self, urlopen: mock.Mock, sleep: mock.Mock + ) -> None: + unauthorized = static_site.urllib.error.HTTPError( + "https://storage.example/example/.static-site-manifest.json", + 401, + "Unauthorized", + {}, + BytesIO(b'{"HttpCode":401,"Message":"Unauthorized"}'), + ) + response = mock.MagicMock() + response.__enter__.return_value.read.return_value = b"manifest" + urlopen.side_effect = [unauthorized, response] + storage = static_site.BunnyStorage( + "example", "new-password", "storage.example" + ) + + self.assertEqual(storage.get_manifest(), b"manifest") + sleep.assert_called_once_with(1) + self.assertEqual(urlopen.call_count, 2) + @mock.patch.object(static_site.urllib.request, "urlopen") def test_gateway_reuses_storage_password(self, urlopen: mock.Mock) -> None: urlopen.return_value.__enter__.return_value = object()