mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
224 lines
7.3 KiB
Python
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"]
|