diff --git a/backend/epc_client/_retry.py b/backend/epc_client/_retry.py index e290e95b..bbdd0cff 100644 --- a/backend/epc_client/_retry.py +++ b/backend/epc_client/_retry.py @@ -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] diff --git a/backend/epc_client/epc_client_service.py b/backend/epc_client/epc_client_service.py index b1ed2017..86caeea3 100644 --- a/backend/epc_client/epc_client_service.py +++ b/backend/epc_client/epc_client_service.py @@ -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}") diff --git a/backend/epc_client/exceptions.py b/backend/epc_client/exceptions.py index 49f1542a..fb7d96fa 100644 --- a/backend/epc_client/exceptions.py +++ b/backend/epc_client/exceptions.py @@ -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