"""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 from typing import Any, Optional import httpx from domain.geospatial.coordinates import Coordinates from infrastructure.http_retry import TransientHttpError, call_with_retry 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: """The parsed ``result`` payload for a postcodes.io GET, retried on transient failures via the shared ``call_with_retry``, 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. The soft-fail is the difference from the EPC client, which lets the error propagate.""" try: return call_with_retry( lambda: self._fetch(path, params), max_retries=self.MAX_RETRIES, backoff_base=self.BACKOFF_BASE, backoff_multiplier=self.BACKOFF_MULTIPLIER, max_backoff=self.MAX_BACKOFF, ) except (TransientHttpError, httpx.HTTPError): return None def _fetch(self, path: str, params: Optional[dict[str, Any]]) -> Any: """One GET. Raises ``TransientHttpError`` on a 429/5xx (and lets ``httpx.TransportError`` propagate) so ``call_with_retry`` retries it; returns None for a non-transient non-success (e.g. 404) or unparseable body.""" response = httpx.get( f"{self.BASE_URL}{path}", params=params, timeout=self.REQUEST_TIMEOUT, ) if self._is_transient(response.status_code): raise TransientHttpError( f"postcodes.io {response.status_code} on {path}", retry_after=self._retry_after(response), ) 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 @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