fix(static-sites): retry Bunny zone credential propagation

This commit is contained in:
Eric Wendland 2026-07-20 00:37:47 +02:00
commit d9ea6cf9b7
2 changed files with 55 additions and 18 deletions

View file

@ -11,6 +11,7 @@ import os
import subprocess import subprocess
import sys import sys
import tarfile import tarfile
import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
@ -21,6 +22,8 @@ from typing import Any
MANIFEST_NAME = ".static-site-manifest.json" MANIFEST_NAME = ".static-site-manifest.json"
MANIFEST_VERSION = 1 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): class DeployError(RuntimeError):
@ -113,24 +116,34 @@ class BunnyStorage:
) -> bytes | None: ) -> bytes | None:
url_path = urllib.parse.quote(path, safe="/") url_path = urllib.parse.quote(path, safe="/")
request_headers = {"AccessKey": self.password, **(headers or {})} request_headers = {"AccessKey": self.password, **(headers or {})}
request = urllib.request.Request( for attempt in range(len(BUNNY_RETRY_DELAYS) + 1):
f"{self.base_url}/{url_path}", request = urllib.request.Request(
data=body, f"{self.base_url}/{url_path}",
headers=request_headers, data=body,
method=method, headers=request_headers,
) method=method,
try: )
with urllib.request.urlopen(request, timeout=60) as response: try:
return response.read() with urllib.request.urlopen(request, timeout=60) as response:
except urllib.error.HTTPError as exc: return response.read()
if missing_ok and exc.code == 404: except urllib.error.HTTPError as exc:
return None if missing_ok and exc.code == 404:
detail = exc.read().decode("utf-8", errors="replace") return None
raise DeployError( detail = exc.read().decode("utf-8", errors="replace")
f"Bunny Storage {method} {path} failed: HTTP {exc.code}: {detail}" if (
) from exc exc.code in BUNNY_TRANSIENT_STATUS_CODES
except urllib.error.URLError as exc: and attempt < len(BUNNY_RETRY_DELAYS)
raise DeployError(f"Bunny Storage {method} {path} failed: {exc.reason}") from exc ):
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: def get_manifest(self) -> bytes | None:
return self._request("GET", MANIFEST_NAME, missing_ok=True) return self._request("GET", MANIFEST_NAME, missing_ok=True)

View file

@ -1,4 +1,5 @@
import importlib.util import importlib.util
from io import BytesIO
import json import json
from pathlib import Path from pathlib import Path
import sys import sys
@ -91,6 +92,29 @@ class StaticSiteTests(unittest.TestCase):
with self.assertRaisesRegex(DeployError, "HTTPS origin"): with self.assertRaisesRegex(DeployError, "HTTPS origin"):
purge_bunny_gateway(endpoint, "example-org", "password") 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") @mock.patch.object(static_site.urllib.request, "urlopen")
def test_gateway_reuses_storage_password(self, urlopen: mock.Mock) -> None: def test_gateway_reuses_storage_password(self, urlopen: mock.Mock) -> None:
urlopen.return_value.__enter__.return_value = object() urlopen.return_value.__enter__.return_value = object()