diff --git a/backend/apis/GoogleSolarApi.py b/backend/apis/GoogleSolarApi.py index 6fc5daa6..7c90307c 100644 --- a/backend/apis/GoogleSolarApi.py +++ b/backend/apis/GoogleSolarApi.py @@ -1,13 +1,15 @@ -import time -import requests import pandas as pd import numpy as np -from typing import List +from typing import Any, List from functools import lru_cache from sklearn.preprocessing import MinMaxScaler from tqdm import tqdm from math import sin, cos, sqrt, atan2, radians +from infrastructure.solar.google_solar_api_client import ( + BuildingInsightsNotFoundError, + GoogleSolarApiClient, +) from utils.logger import setup_logger from recommendations.Costs import Costs from backend.ml_models.AnnualBillSavings import AnnualBillSavings @@ -57,19 +59,9 @@ class GoogleSolarApi: # that we calcualte based on the property dimensions, we will correct the roof area ROOF_AREA_TOLERANCE = 1.25 - # Error Messages - ENTITY_NOT_FOUND_ERROR = 'Requested entity was not found.' - - def __init__(self, api_key, solar_materials: list, max_retries=5): - """ - Initialize the GoogleSolarApi class with the provided API key and maximum retries. - - :param api_key: The API key to authenticate requests to the Google Solar API. - :param max_retries: The maximum number of retries for the API request (default is 5). - """ + def __init__(self, api_key: str, solar_materials: list) -> None: self.api_key = api_key - self.max_retries = max_retries - self.base_url = "https://solar.googleapis.com/v1" + self._solar_client = GoogleSolarApiClient(api_key) self.insights_data = None self.roof_segments = [] @@ -90,48 +82,11 @@ class GoogleSolarApi: self.allowed_segment_indices = None - def get_building_insights(self, longitude, latitude, required_quality="MEDIUM", max_retries=None): - """ - Make an API request to retrieve building insights based on the given longitude and latitude, with retry - mechanism. - - :param longitude: The longitude of the location. - :param latitude: The latitude of the location. - :param required_quality: The required quality of the data (default is "MEDIUM"). - :param max_retries: The maximum number of retries for the API request (default is None, which uses the - instance's max_retries). - :return: The JSON response containing the building insights data. - """ - if max_retries is None: - max_retries = self.max_retries - - insights_url = f"{self.base_url}/buildingInsights:findClosest" - params = { - 'location.latitude': f'{latitude:.5f}', - 'location.longitude': f'{longitude:.5f}', - 'requiredQuality': required_quality, - 'key': self.api_key - } - - attempt = 0 - while attempt < max_retries: - try: - response = requests.get(insights_url, params=params) - response.raise_for_status() # Raise an error for bad status codes - return response.json() - except requests.exceptions.RequestException as e: - if ( - (e.response.status_code == 404) & - (e.response.json()["error"]["message"] == self.ENTITY_NOT_FOUND_ERROR) - ): - logger.warning("No building insights found for the given location.") - return {"error": self.ENTITY_NOT_FOUND_ERROR} - - attempt += 1 - print(f"Attempt {attempt} failed: {e}") - time.sleep(2 ** attempt) # Exponential backoff - if attempt >= max_retries: - raise + def get_building_insights(self, longitude: float, latitude: float, required_quality: str = "MEDIUM") -> dict[str, Any]: + try: + return self._solar_client.get_building_insights(longitude, latitude, required_quality) # type: ignore[arg-type] + except BuildingInsightsNotFoundError: + return {"error": GoogleSolarApiClient.ENTITY_NOT_FOUND_ERROR} @lru_cache(maxsize=128) def get( diff --git a/infrastructure/solar/__init__.py b/infrastructure/solar/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/infrastructure/solar/google_solar_api_client.py b/infrastructure/solar/google_solar_api_client.py new file mode 100644 index 00000000..28e91866 --- /dev/null +++ b/infrastructure/solar/google_solar_api_client.py @@ -0,0 +1,50 @@ +import time +from typing import Any, Literal, Optional + +import requests + + +class BuildingInsightsNotFoundError(Exception): + pass + + +class GoogleSolarApiClient: + base_url: str = "https://solar.googleapis.com/v1" + MAX_RETRIES: int = 5 + ENTITY_NOT_FOUND_ERROR: str = "Requested entity was not found." + + def __init__(self, api_key: str) -> None: + self._api_key = api_key + + def get_building_insights( + self, + longitude: float, + latitude: float, + required_quality: Literal["HIGH", "MEDIUM", "LOW"] = "MEDIUM", + ) -> dict[str, Any]: + insights_url = f"{self.base_url}/buildingInsights:findClosest" + params: dict[str, str] = { + "location.latitude": f"{latitude:.5f}", + "location.longitude": f"{longitude:.5f}", + "requiredQuality": required_quality, + "key": self._api_key, + } + last_exc: Optional[Exception] = None + for attempt in range(self.MAX_RETRIES): + try: + response = requests.get(insights_url, params=params) + response.raise_for_status() + result: dict[str, Any] = response.json() + return result + except requests.exceptions.RequestException as e: + if ( + e.response is not None + and e.response.status_code == 404 + and e.response.json()["error"]["message"] == self.ENTITY_NOT_FOUND_ERROR + ): + raise BuildingInsightsNotFoundError() from e + last_exc = e + time.sleep(2 ** attempt) + + assert last_exc is not None + raise last_exc diff --git a/tests/infrastructure/solar/__init__.py b/tests/infrastructure/solar/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/infrastructure/solar/test_google_solar_api_client.py b/tests/infrastructure/solar/test_google_solar_api_client.py new file mode 100644 index 00000000..d4328fc0 --- /dev/null +++ b/tests/infrastructure/solar/test_google_solar_api_client.py @@ -0,0 +1,110 @@ +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from infrastructure.solar.google_solar_api_client import ( + BuildingInsightsNotFoundError, + GoogleSolarApiClient, +) + + +# --------------------------------------------------------------------------- +# 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_propagates_exception_after_retry_exhaustion() -> None: + # Arrange + 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("time.sleep"): + # Act / Assert + with pytest.raises(requests.exceptions.ConnectionError): + client.get_building_insights(longitude=-0.1278, latitude=51.5074) + + assert mock_get.call_count == GoogleSolarApiClient.MAX_RETRIES diff --git a/tests/infrastructure/solar/test_google_solar_api_wiring.py b/tests/infrastructure/solar/test_google_solar_api_wiring.py new file mode 100644 index 00000000..77988645 --- /dev/null +++ b/tests/infrastructure/solar/test_google_solar_api_wiring.py @@ -0,0 +1,47 @@ +from typing import Any +from unittest.mock import patch + +from backend.apis.GoogleSolarApi import GoogleSolarApi +from infrastructure.solar.google_solar_api_client import ( + BuildingInsightsNotFoundError, + GoogleSolarApiClient, +) + + +# --------------------------------------------------------------------------- +# Slice 1: get_building_insights delegates to _solar_client +# --------------------------------------------------------------------------- + + +def test_get_building_insights_delegates_to_solar_client() -> None: + # Arrange + payload: dict[str, Any] = {"solarPotential": {"maxArrayPanelsCount": 20}} + with patch.object(GoogleSolarApiClient, "get_building_insights", return_value=payload): + api = GoogleSolarApi(api_key="test-key", solar_materials=[]) + + # Act + result = api.get_building_insights(longitude=-0.1278, latitude=51.5074) + + # Assert + assert result == payload + + +# --------------------------------------------------------------------------- +# Slice 2: BuildingInsightsNotFoundError translates to sentinel dict +# --------------------------------------------------------------------------- + + +def test_get_building_insights_returns_sentinel_on_not_found() -> None: + # Arrange + with patch.object( + GoogleSolarApiClient, + "get_building_insights", + side_effect=BuildingInsightsNotFoundError, + ): + api = GoogleSolarApi(api_key="test-key", solar_materials=[]) + + # Act + result = api.get_building_insights(longitude=-0.1278, latitude=51.5074) + + # Assert + assert result == {"error": GoogleSolarApiClient.ENTITY_NOT_FOUND_ERROR}