Model/tests/infrastructure/postcodes_io/test_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

224 lines
7.3 KiB
Python

"""PostcodesIoClient — coordinate/seed postcode → the real unit postcodes near
it, via postcodes.io's keyless nearest endpoint. Failure degrades to the seed
alone so broadening never breaks prediction."""
from __future__ import annotations
from typing import Any, Iterator, Optional
from unittest.mock import MagicMock, patch
import httpx
import pytest
from domain.geospatial.coordinates import Coordinates
from infrastructure.postcodes_io.postcodes_io_client import PostcodesIoClient
_MODULE = "infrastructure.postcodes_io.postcodes_io_client"
@pytest.fixture(autouse=True)
def _no_sleep() -> Iterator[MagicMock]:
"""Never actually sleep during backoff (the shared retry owns the sleep) —
just record the calls."""
with patch("infrastructure.http_retry.time.sleep") as sleep:
yield sleep
def _response(
payload: Any,
*,
status_code: int = 200,
headers: Optional[dict[str, str]] = None,
) -> MagicMock:
resp = MagicMock()
resp.status_code = status_code
resp.is_success = 200 <= status_code < 300
resp.headers = headers if headers is not None else {}
resp.json.return_value = payload
return resp
def _nearest_payload(postcodes: list[str]) -> dict[str, Any]:
return {"result": [{"postcode": p} for p in postcodes]}
def test_nearby_with_coordinates_skips_the_centroid_lookup() -> None:
"""When the target's own coordinates are passed, only the radius search is
issued — no postcode→centroid round-trip — and the seed leads the result."""
# Arrange
client = PostcodesIoClient(radius_m=500, limit=10)
coords = Coordinates(longitude=0.1, latitude=51.3)
with patch(f"{_MODULE}.httpx.get") as mock_get:
mock_get.return_value = _response(
_nearest_payload(["BR6 6BS", "BR6 6BU", "BR6 6NX"])
)
# Act
result = client.nearby("BR6 6BS", coords)
# Assert — one call (the radius search), seed first, neighbours follow
assert result == ["BR6 6BS", "BR6 6BU", "BR6 6NX"]
assert mock_get.call_count == 1
_, kwargs = mock_get.call_args
assert kwargs["params"]["lat"] == 51.3
assert kwargs["params"]["lon"] == 0.1
def test_nearby_resolves_the_seed_centroid_when_no_coordinates_given() -> None:
"""Without coordinates the client first resolves the seed's own centroid via
postcodes.io, then runs the radius search from it."""
# Arrange
client = PostcodesIoClient()
centroid = {"result": {"latitude": 51.3, "longitude": 0.1}}
with patch(f"{_MODULE}.httpx.get") as mock_get:
mock_get.side_effect = [
_response(centroid),
_response(_nearest_payload(["BR6 6BS", "BR6 6BU"])),
]
# Act
result = client.nearby("BR6 6BS")
# Assert — two calls: centroid then radius
assert result == ["BR6 6BS", "BR6 6BU"]
assert mock_get.call_count == 2
def test_nearby_dedupes_the_seed_and_caps_at_limit() -> None:
"""The seed always leads exactly once even when the radius search echoes it,
and the result is capped at ``limit``."""
# Arrange
client = PostcodesIoClient(limit=3)
coords = Coordinates(longitude=0.1, latitude=51.3)
with patch(f"{_MODULE}.httpx.get") as mock_get:
mock_get.return_value = _response(
_nearest_payload(["BR6 6BS", "BR6 6BU", "BR6 6NX", "BR6 6AA"])
)
# Act
result = client.nearby("BR6 6BS", coords)
# Assert
assert result == ["BR6 6BS", "BR6 6BU", "BR6 6NX"]
assert result.count("BR6 6BS") == 1
def test_nearby_returns_just_the_seed_after_exhausting_retries(
_no_sleep: MagicMock,
) -> None:
"""A persistent network error is retried, then degrades to broadening-off:
only the seed comes back, and the retries were actually attempted."""
# Arrange
client = PostcodesIoClient()
coords = Coordinates(longitude=0.1, latitude=51.3)
with patch(
f"{_MODULE}.httpx.get", side_effect=httpx.ConnectError("down")
) as mock_get:
# Act
result = client.nearby("BR6 6BS", coords)
# Assert — one initial try + MAX_RETRIES, sleeping between each.
assert result == ["BR6 6BS"]
assert mock_get.call_count == client.MAX_RETRIES + 1
assert _no_sleep.call_count == client.MAX_RETRIES
def test_nearby_retries_a_transport_error_then_succeeds(_no_sleep: MagicMock) -> None:
"""A transient transport error is retried, and the subsequent success is
returned in full."""
# Arrange
client = PostcodesIoClient()
coords = Coordinates(longitude=0.1, latitude=51.3)
with patch(f"{_MODULE}.httpx.get") as mock_get:
mock_get.side_effect = [
httpx.ReadTimeout("slow"),
_response(_nearest_payload(["BR6 6BS", "BR6 6BU"])),
]
# Act
result = client.nearby("BR6 6BS", coords)
# Assert
assert result == ["BR6 6BS", "BR6 6BU"]
assert mock_get.call_count == 2
assert _no_sleep.call_count == 1
def test_nearby_retries_a_429_honouring_retry_after(_no_sleep: MagicMock) -> None:
"""A 429 is retried, and the server's Retry-After drives the backoff delay."""
# Arrange
client = PostcodesIoClient()
coords = Coordinates(longitude=0.1, latitude=51.3)
with patch(f"{_MODULE}.httpx.get") as mock_get:
mock_get.side_effect = [
_response(None, status_code=429, headers={"Retry-After": "2"}),
_response(_nearest_payload(["BR6 6BS", "BR6 6BU"])),
]
# Act
result = client.nearby("BR6 6BS", coords)
# Assert — succeeded on the retry, having slept the advertised 2 seconds.
assert result == ["BR6 6BS", "BR6 6BU"]
assert mock_get.call_count == 2
_no_sleep.assert_called_once_with(2.0)
def test_nearby_retries_a_server_error_then_succeeds(_no_sleep: MagicMock) -> None:
"""A 5xx is treated as transient and retried."""
# Arrange
client = PostcodesIoClient()
coords = Coordinates(longitude=0.1, latitude=51.3)
with patch(f"{_MODULE}.httpx.get") as mock_get:
mock_get.side_effect = [
_response(None, status_code=503),
_response(_nearest_payload(["BR6 6BS"])),
]
# Act
result = client.nearby("BR6 6BS", coords)
# Assert
assert result == ["BR6 6BS"]
assert mock_get.call_count == 2
def test_nearby_returns_just_the_seed_when_centroid_unresolvable() -> None:
"""An unknown seed (no coordinates, centroid lookup fails) yields the seed
alone rather than raising."""
# Arrange
client = PostcodesIoClient()
with patch(f"{_MODULE}.httpx.get") as mock_get:
mock_get.return_value = _response(None, status_code=404)
# Act
result: list[str] = client.nearby("ZZ99 9ZZ")
# Assert — a 404 is non-transient, so no retry was attempted.
assert result == ["ZZ99 9ZZ"]
assert mock_get.call_count == 1
def test_nearby_tolerates_a_null_nearest_result() -> None:
"""postcodes.io returns ``result: null`` when a point has no neighbours; the
client treats that as an empty neighbour set (seed only)."""
# Arrange
client = PostcodesIoClient()
coords: Optional[Coordinates] = Coordinates(longitude=0.1, latitude=51.3)
with patch(f"{_MODULE}.httpx.get") as mock_get:
mock_get.return_value = _response({"result": None})
# Act
result = client.nearby("BR6 6BS", coords)
# Assert
assert result == ["BR6 6BS"]