mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
250 lines
9.7 KiB
Python
250 lines
9.7 KiB
Python
from typing import Any, Optional
|
|
from unittest.mock import MagicMock, call, patch
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
from infrastructure.http_retry import TransientHttpError
|
|
from infrastructure.solar.google_solar_api_client import (
|
|
BuildingInsightsNotFoundError,
|
|
GoogleSolarApiClient,
|
|
)
|
|
|
|
|
|
def _http_error(
|
|
status_code: int, *, headers: Optional[dict[str, str]] = None
|
|
) -> requests.exceptions.HTTPError:
|
|
resp = MagicMock()
|
|
resp.status_code = status_code
|
|
resp.headers = headers or {}
|
|
err = requests.exceptions.HTTPError(str(status_code))
|
|
err.response = resp
|
|
return err
|
|
|
|
|
|
def _raising_response(err: Exception) -> MagicMock:
|
|
resp = MagicMock()
|
|
resp.raise_for_status.side_effect = err
|
|
return resp
|
|
|
|
|
|
def test_get_building_insights_retries_429_then_succeeds() -> None:
|
|
# Arrange — a rate-limit (429) then a success. The 429 is transient, so the
|
|
# client backs off and retries rather than failing.
|
|
client = GoogleSolarApiClient(api_key="test-key")
|
|
payload: dict[str, Any] = {"solarPotential": {"maxArrayPanelsCount": 20}}
|
|
ok = MagicMock()
|
|
ok.json.return_value = payload
|
|
ok.raise_for_status.return_value = None
|
|
|
|
with patch("requests.get", side_effect=[_raising_response(_http_error(429)), ok]) as mock_get:
|
|
with patch("infrastructure.http_retry.time.sleep"):
|
|
# Act
|
|
result = client.get_building_insights(longitude=-0.1278, latitude=51.5074)
|
|
|
|
# Assert
|
|
assert result == payload
|
|
assert mock_get.call_count == 2
|
|
|
|
|
|
def test_get_building_insights_honours_retry_after_on_429() -> None:
|
|
# Arrange — a persistent 429 advertising Retry-After: 2s. The client sleeps
|
|
# exactly that (not a jittered computed backoff) and gives up bounded.
|
|
client = GoogleSolarApiClient(api_key="test-key")
|
|
err = _http_error(429, headers={"Retry-After": "2"})
|
|
|
|
with patch("requests.get", return_value=_raising_response(err)) as mock_get:
|
|
with patch("infrastructure.http_retry.time.sleep") as sleep:
|
|
# Act / Assert — exhaustion raises a TransientHttpError, not the raw HTTPError.
|
|
with pytest.raises(TransientHttpError):
|
|
client.get_building_insights(longitude=-0.1278, latitude=51.5074)
|
|
|
|
# Bounded: MAX_RETRIES retries + the initial attempt; Retry-After honoured.
|
|
assert mock_get.call_count == GoogleSolarApiClient.MAX_RETRIES + 1
|
|
assert call(2.0) in sleep.call_args_list
|
|
|
|
|
|
def test_get_building_insights_retries_on_500() -> None:
|
|
# Arrange — a 5xx is transient too; retry then succeed.
|
|
client = GoogleSolarApiClient(api_key="test-key")
|
|
payload: dict[str, Any] = {"solarPotential": {}}
|
|
ok = MagicMock()
|
|
ok.json.return_value = payload
|
|
ok.raise_for_status.return_value = None
|
|
|
|
with patch("requests.get", side_effect=[_raising_response(_http_error(503)), ok]) as mock_get:
|
|
with patch("infrastructure.http_retry.time.sleep"):
|
|
# Act
|
|
result = client.get_building_insights(longitude=-0.1278, latitude=51.5074)
|
|
|
|
# Assert
|
|
assert result == payload
|
|
assert mock_get.call_count == 2
|
|
|
|
|
|
def test_get_building_insights_does_not_retry_a_400() -> None:
|
|
# Arrange — a non-transient client error (e.g. bad request) propagates at once.
|
|
client = GoogleSolarApiClient(api_key="test-key")
|
|
|
|
with patch("requests.get", return_value=_raising_response(_http_error(400))) as mock_get:
|
|
with patch("infrastructure.http_retry.time.sleep"):
|
|
# Act / Assert
|
|
with pytest.raises(requests.exceptions.HTTPError):
|
|
client.get_building_insights(longitude=-0.1278, latitude=51.5074)
|
|
|
|
assert mock_get.call_count == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Throttle: per-instance min-interval keeps the fleet under the 600 QPM cap
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_throttle_spaces_consecutive_calls_by_min_interval() -> None:
|
|
# Arrange — a client paced at a 4s minimum gap between its OWN Solar calls
|
|
# (quota/N at the 32-wide fleet: 0.8 * 600 QPM / 32 ≈ one call / 4s). The
|
|
# first call fires immediately; the second must wait out the remainder of the
|
|
# interval before hitting the API, so the fleet stays under the hard 600 QPM
|
|
# ceiling instead of sustaining the over-quota burst that 429'd portfolio 796.
|
|
client = GoogleSolarApiClient(
|
|
api_key="test-key", min_request_interval_seconds=4.0
|
|
)
|
|
ok = MagicMock()
|
|
ok.json.return_value = {"solarPotential": {}}
|
|
ok.raise_for_status.return_value = None
|
|
|
|
with patch("requests.get", return_value=ok):
|
|
with patch(
|
|
"infrastructure.solar.google_solar_api_client.time.sleep"
|
|
) as sleep:
|
|
# Act — two back-to-back calls with no real work between them.
|
|
client.get_building_insights(longitude=-0.1278, latitude=51.5074)
|
|
client.get_building_insights(longitude=-0.1278, latitude=51.5074)
|
|
|
|
# Assert — first call doesn't wait; second waits ~the full interval (no time
|
|
# elapsed between them, so the remaining gap is essentially the whole 4s).
|
|
assert sleep.call_count == 1
|
|
(waited,), _ = sleep.call_args
|
|
assert 3.5 <= waited <= 4.0
|
|
|
|
|
|
def test_throttle_off_by_default_does_not_sleep() -> None:
|
|
# Arrange — no interval configured (the interactive backend path): the client
|
|
# must not pace, preserving today's behaviour.
|
|
client = GoogleSolarApiClient(api_key="test-key")
|
|
ok = MagicMock()
|
|
ok.json.return_value = {"solarPotential": {}}
|
|
ok.raise_for_status.return_value = None
|
|
|
|
with patch("requests.get", return_value=ok):
|
|
with patch(
|
|
"infrastructure.solar.google_solar_api_client.time.sleep"
|
|
) as sleep:
|
|
# Act
|
|
client.get_building_insights(longitude=-0.1278, latitude=51.5074)
|
|
client.get_building_insights(longitude=-0.1278, latitude=51.5074)
|
|
|
|
# Assert
|
|
sleep.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slice 1: Successful response returns parsed JSON
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_building_insights_returns_parsed_json() -> None:
|
|
# Arrange
|
|
client = GoogleSolarApiClient(api_key="test-key")
|
|
payload: dict[str, Any] = {"solarPotential": {"maxArrayPanelsCount": 20}}
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = payload
|
|
|
|
with patch("requests.get", return_value=mock_response) as mock_get:
|
|
mock_response.raise_for_status.return_value = None
|
|
|
|
# Act
|
|
result = client.get_building_insights(longitude=-0.1278, latitude=51.5074)
|
|
|
|
# Assert
|
|
assert result == payload
|
|
mock_get.assert_called_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slice 2: Transient HTTP errors trigger retries
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_building_insights_retries_on_transient_error() -> None:
|
|
# Arrange
|
|
client = GoogleSolarApiClient(api_key="test-key")
|
|
payload: dict[str, Any] = {"solarPotential": {"maxArrayPanelsCount": 20}}
|
|
|
|
transient_error = requests.exceptions.ConnectionError("timeout")
|
|
transient_error.response = None # type: ignore[attr-defined]
|
|
|
|
success_response = MagicMock()
|
|
success_response.json.return_value = payload
|
|
success_response.raise_for_status.return_value = None
|
|
|
|
with patch("requests.get", side_effect=[transient_error, success_response]) as mock_get:
|
|
with patch("time.sleep"):
|
|
# Act
|
|
result = client.get_building_insights(longitude=-0.1278, latitude=51.5074)
|
|
|
|
# Assert
|
|
assert result == payload
|
|
assert mock_get.call_count == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slice 3: 404 + entity-not-found raises BuildingInsightsNotFoundError
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_building_insights_raises_on_entity_not_found() -> None:
|
|
# Arrange
|
|
client = GoogleSolarApiClient(api_key="test-key")
|
|
|
|
not_found_response = MagicMock()
|
|
not_found_response.status_code = 404
|
|
not_found_response.json.return_value = {
|
|
"error": {"message": GoogleSolarApiClient.ENTITY_NOT_FOUND_ERROR}
|
|
}
|
|
|
|
http_error = requests.exceptions.HTTPError("404")
|
|
http_error.response = not_found_response
|
|
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.return_value.raise_for_status.side_effect = http_error
|
|
mock_get.return_value.response = not_found_response
|
|
|
|
# Act / Assert
|
|
with pytest.raises(BuildingInsightsNotFoundError):
|
|
client.get_building_insights(longitude=-0.1278, latitude=51.5074)
|
|
|
|
assert mock_get.call_count == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slice 4: Retry exhaustion propagates the exception to the caller
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_building_insights_fails_bounded_after_retry_exhaustion() -> None:
|
|
# Arrange — a persistent transport failure (no response). It's transient, so
|
|
# the client retries up to the bound, then surfaces a TransientHttpError for
|
|
# the caller to fail the subtask on (re-triggerable) — NOT an infinite loop.
|
|
client = GoogleSolarApiClient(api_key="test-key")
|
|
|
|
error = requests.exceptions.ConnectionError("persistent failure")
|
|
error.response = None # type: ignore[attr-defined]
|
|
|
|
with patch("requests.get", side_effect=error) as mock_get:
|
|
with patch("infrastructure.http_retry.time.sleep"):
|
|
# Act / Assert
|
|
with pytest.raises(TransientHttpError):
|
|
client.get_building_insights(longitude=-0.1278, latitude=51.5074)
|
|
|
|
assert mock_get.call_count == GoogleSolarApiClient.MAX_RETRIES + 1
|