Model/infrastructure/postcodes_io/postcodes_io_client.py
Khalim Conn-Kowlessar de7fb94ff7 docs(adr): record nearby-postcode broadening (0034) + share HTTP retry primitive
Closes out the cohort-broadening work with its decision record and consolidates
the retry plumbing.

ADR-0034 documents broadening the EPC-Prediction cohort to the real unit
postcodes nearest the target (via postcodes.io) when its own postcode holds no
same-type comparable — extending ADR-0031 decision 5. Records why postcodes.io
was chosen over council[] (whole-LA, no property_type in rows), a bulk Code-Point
Open / ONSPD dataset, and the OS Places radius API, and the lazy / nearest-first
early-stop / soft-fail policy. Broadening-specific docstrings now cite 0034.

Retry consolidation: extract the EPC client's call_with_retry into a shared
infrastructure/http_retry.py keyed off a generic TransientHttpError marker, so
the mechanism (exponential backoff, Retry-After) is shared while each client
keeps its own transient policy. EpcRateLimitError now subclasses TransientHttpError
(still an EpcApiError); PostcodesIoClient routes through the same helper, raising
TransientHttpError on 429/5xx and soft-failing to the seed once exhausted (the EPC
client propagates instead). Direct tests for the shared helper; EPC + postcodes.io
suites repointed at the shared sleep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:54:06 +00:00

145 lines
5.8 KiB
Python

"""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