"""postcodes.io adapter — a coordinate (or seed postcode) → the real unit postcodes physically near it. The gov EPC API only searches a *full* real postcode — no outcode/prefix, no radius, no lat/long (confirmed against its OpenAPI spec). So to broaden an EPC-Prediction cohort beyond the target's own postcode we must first discover the real unit postcodes around it. postcodes.io's free, keyless ``nearest`` endpoint does exactly that: given a point it returns the unit postcodes within a radius, nearest first. Failure is deliberately non-fatal: any error (network, unknown seed, missing coordinates) returns just the seed postcode, so broadening degrades to "no broadening" rather than breaking prediction. """ from __future__ import annotations import time from typing import Any, Optional import httpx from domain.geospatial.coordinates import Coordinates class PostcodesIoClient: BASE_URL = "https://api.postcodes.io" REQUEST_TIMEOUT = 10.0 # Transient failures (transport errors, 429s, 5xx) are retried with # exponential backoff; everything else (and exhaustion) soft-fails to the # seed, so broadening never breaks prediction. MAX_RETRIES = 3 BACKOFF_BASE = 0.5 BACKOFF_MULTIPLIER = 2.0 MAX_BACKOFF = 8.0 def __init__(self, *, radius_m: int = 1000, limit: int = 30) -> None: """``radius_m`` bounds how far the broadened cohort reaches; ``limit`` caps how many nearby postcodes are returned (and so the per-gate-out fetch cost).""" self._radius_m = radius_m self._limit = limit def nearby( self, postcode: str, coordinates: Optional[Coordinates] = None ) -> list[str]: """The real unit postcodes within ``radius_m`` of ``postcode`` — nearest first, the seed always included — or just ``[postcode]`` when the seed's coordinates cannot be resolved or the lookup fails. ``coordinates`` (the target's own, resolved from its UPRN) is used when given, sparing a postcode→centroid round-trip; otherwise postcodes.io resolves the seed postcode's centroid itself.""" point = coordinates if coordinates is not None else self._centroid_of(postcode) if point is None: return [postcode] found = self._nearest_to(point) ordered = [postcode] + [p for p in found if p != postcode] return ordered[: self._limit] def _centroid_of(self, postcode: str) -> Optional[Coordinates]: result = self._get(f"/postcodes/{postcode.replace(' ', '')}") if result is None: return None latitude: Any = result.get("latitude") longitude: Any = result.get("longitude") if latitude is None or longitude is None: return None return Coordinates(longitude=float(longitude), latitude=float(latitude)) def _nearest_to(self, point: Coordinates) -> list[str]: results = self._get_list( "/postcodes", { "lon": point.longitude, "lat": point.latitude, "radius": self._radius_m, "limit": self._limit, }, ) return [str(row["postcode"]) for row in results if row.get("postcode")] def _get(self, path: str) -> Optional[dict[str, Any]]: payload = self._call(path, None) return payload if isinstance(payload, dict) else None def _get_list(self, path: str, params: dict[str, Any]) -> list[dict[str, Any]]: payload = self._call(path, params) if not isinstance(payload, list): return [] return [row for row in payload if isinstance(row, dict)] def _call(self, path: str, params: Optional[dict[str, Any]]) -> Any: """One GET against postcodes.io, retrying transient failures (transport errors, 429s, 5xx) with exponential backoff. Returns the parsed ``result`` payload, or None on a non-transient failure (e.g. an unknown postcode's 404) or once retries are exhausted — broadening then falls back to the seed alone.""" for attempt in range(self.MAX_RETRIES + 1): try: response = httpx.get( f"{self.BASE_URL}{path}", params=params, timeout=self.REQUEST_TIMEOUT, ) except httpx.TransportError: if not self._sleep_before_retry(attempt, retry_after=None): return None continue except httpx.HTTPError: return None # non-transient client-side error (e.g. bad URL) if self._is_transient(response.status_code): if not self._sleep_before_retry( attempt, retry_after=self._retry_after(response) ): return None continue if not response.is_success: return None try: body: Any = response.json() except ValueError: return None return body.get("result") if isinstance(body, dict) else None return None def _sleep_before_retry(self, attempt: int, retry_after: Optional[float]) -> bool: """Sleep before the next attempt and report whether one remains; on the final attempt, return False so the caller soft-fails instead of looping.""" if attempt >= self.MAX_RETRIES: return False if retry_after is not None: delay = retry_after else: delay = self.BACKOFF_BASE * (self.BACKOFF_MULTIPLIER**attempt) time.sleep(min(delay, self.MAX_BACKOFF)) return True @staticmethod def _is_transient(status_code: int) -> bool: return status_code == 429 or status_code >= 500 @staticmethod def _retry_after(response: httpx.Response) -> Optional[float]: header = response.headers.get("Retry-After") if header is None: return None try: return float(header) except (TypeError, ValueError): return None