Model/tests/infrastructure/test_http_retry.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

126 lines
3.1 KiB
Python

"""call_with_retry — the shared transient-failure retry both HTTP source clients
use. Generic mechanism (exponential backoff, Retry-After), per-client policy
(what gets raised as transient)."""
from __future__ import annotations
from typing import Iterator
from unittest.mock import MagicMock, call, patch
import httpx
import pytest
from infrastructure.http_retry import TransientHttpError, call_with_retry
@pytest.fixture(autouse=True)
def _no_sleep() -> Iterator[MagicMock]:
with patch("infrastructure.http_retry.time.sleep") as sleep:
yield sleep
def test_returns_immediately_on_success(_no_sleep: MagicMock) -> None:
# Act
result = call_with_retry(lambda: 42)
# Assert
assert result == 42
_no_sleep.assert_not_called()
def test_retries_a_transient_error_then_succeeds(_no_sleep: MagicMock) -> None:
# Arrange — fail twice transiently, then succeed.
calls = {"n": 0}
def fn() -> str:
calls["n"] += 1
if calls["n"] < 3:
raise TransientHttpError("429")
return "ok"
# Act
result = call_with_retry(fn)
# Assert — exponential backoff between the three attempts.
assert result == "ok"
assert _no_sleep.call_args_list == [call(1.0), call(2.0)]
def test_honours_retry_after_over_the_computed_backoff(_no_sleep: MagicMock) -> None:
# Arrange
raised = {"done": False}
def fn() -> str:
if not raised["done"]:
raised["done"] = True
raise TransientHttpError("429", retry_after=7.0)
return "ok"
# Act
call_with_retry(fn)
# Assert
_no_sleep.assert_called_once_with(7.0)
def test_retries_transport_errors(_no_sleep: MagicMock) -> None:
# Arrange
attempts = {"n": 0}
def fn() -> str:
attempts["n"] += 1
if attempts["n"] == 1:
raise httpx.ReadTimeout("slow")
return "ok"
# Act
result = call_with_retry(fn)
# Assert
assert result == "ok"
assert attempts["n"] == 2
def test_reraises_the_last_transient_error_once_exhausted(
_no_sleep: MagicMock,
) -> None:
# Arrange — always transient.
def fn() -> str:
raise TransientHttpError("persistent 429")
# Act / Assert
with pytest.raises(TransientHttpError, match="persistent 429"):
call_with_retry(fn, max_retries=2)
assert _no_sleep.call_count == 2
def test_non_transient_error_propagates_without_retry(_no_sleep: MagicMock) -> None:
# Arrange
attempts = {"n": 0}
def fn() -> str:
attempts["n"] += 1
raise ValueError("not transient")
# Act / Assert
with pytest.raises(ValueError, match="not transient"):
call_with_retry(fn)
assert attempts["n"] == 1
_no_sleep.assert_not_called()
def test_retry_after_is_capped_by_max_backoff(_no_sleep: MagicMock) -> None:
# Arrange
raised = {"done": False}
def fn() -> str:
if not raised["done"]:
raised["done"] = True
raise TransientHttpError("429", retry_after=999.0)
return "ok"
# Act
call_with_retry(fn, max_backoff=60.0)
# Assert
_no_sleep.assert_called_once_with(60.0)