This commit is contained in:
Jun-te Kim 2026-05-13 09:34:51 +00:00
parent 27c9752949
commit c347865b9e
3 changed files with 34 additions and 3 deletions

View file

@ -11,6 +11,7 @@ def call_with_retry(
max_retries: int = 5,
backoff_base: float = 1.0,
backoff_multiplier: float = 2.0,
max_backoff: float = 60.0,
) -> T:
last_exc: EpcRateLimitError | None = None
for attempt in range(max_retries + 1):
@ -19,5 +20,9 @@ def call_with_retry(
except EpcRateLimitError as exc:
last_exc = exc
if attempt < max_retries:
time.sleep(backoff_base * (backoff_multiplier ** attempt))
if exc.retry_after is not None:
delay = exc.retry_after
else:
delay = backoff_base * (backoff_multiplier ** attempt)
time.sleep(min(delay, max_backoff))
raise last_exc # type: ignore[misc]

View file

@ -18,6 +18,7 @@ from datatypes.epc.search import EpcSearchResult
class EpcClientService:
BASE_URL = "https://api.get-energy-performance-data.communities.gov.uk"
REQUEST_TIMEOUT = 10.0
def __init__(self, auth_token: str) -> None:
self._headers = {
@ -25,6 +26,16 @@ class EpcClientService:
"Accept": "application/json",
}
@staticmethod
def _parse_retry_after(resp: httpx.Response) -> Optional[float]:
header = resp.headers.get("Retry-After")
if header is None:
return None
try:
return float(header)
except (TypeError, ValueError):
return None
def get_by_certificate_number(self, cert_num: str) -> EpcPropertyData:
raw = call_with_retry(lambda: self._fetch_certificate(cert_num))
return EpcPropertyDataMapper.from_api_response(raw)
@ -48,11 +59,15 @@ class EpcClientService:
f"{self.BASE_URL}/api/certificate",
params={"certificate_number": cert_num},
headers=self._headers,
timeout=self.REQUEST_TIMEOUT,
)
if resp.status_code == 404:
raise EpcNotFoundError(cert_num)
if resp.status_code == 429:
raise EpcRateLimitError("Rate limited by EPC API")
raise EpcRateLimitError(
"Rate limited by EPC API",
retry_after=self._parse_retry_after(resp),
)
if not resp.is_success:
raise EpcApiError(f"EPC API error {resp.status_code}: {resp.text}")
return resp.json()["data"]
@ -72,11 +87,15 @@ class EpcClientService:
f"{self.BASE_URL}/api/domestic/search",
params=params,
headers=self._headers,
timeout=self.REQUEST_TIMEOUT,
)
if resp.status_code == 404:
return []
if resp.status_code == 429:
raise EpcRateLimitError("Rate limited by EPC API")
raise EpcRateLimitError(
"Rate limited by EPC API",
retry_after=self._parse_retry_after(resp),
)
if not resp.is_success:
raise EpcApiError(f"EPC API error {resp.status_code}: {resp.text}")

View file

@ -1,3 +1,6 @@
from typing import Optional
class EpcApiError(Exception):
"""Base for all EPC client errors."""
@ -8,3 +11,7 @@ class EpcNotFoundError(EpcApiError):
class EpcRateLimitError(EpcApiError):
"""Raised when the API returns 429 and all retries are exhausted."""
def __init__(self, message: str, retry_after: Optional[float] = None) -> None:
super().__init__(message)
self.retry_after = retry_after