Model/tests/infrastructure/solar/test_google_solar_api_client.py
Khalim Conn-Kowlessar 48a47590e9 Pace Solar calls per container to stay under the 600 QPM fleet cap 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:59:00 +00:00

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