import time from typing import Any, Literal, Optional import requests from infrastructure.http_retry import TransientHttpError, call_with_retry class BuildingInsightsNotFoundError(Exception): pass class GoogleSolarApiClient: base_url: str = "https://solar.googleapis.com/v1" # Bounded retries (NOT Google's infinite-loop example). With the 60s max # backoff below, 6 retries span ~2 of the Solar API's per-minute (600 QPM) # windows — enough to ride out a transient 429 burst, then give up so a # stuck request can't blow the container timeout; the subtask fails and is # re-triggered. MAX_RETRIES: int = 6 MAX_BACKOFF_SECONDS: float = 60.0 ENTITY_NOT_FOUND_ERROR: str = "Requested entity was not found." def __init__( self, api_key: str, min_request_interval_seconds: float = 0.0 ) -> None: # Per-instance min-interval throttle. The Solar API caps the whole GCP # project at a hard 600 QPM shared across Building Insights + Data Layers # (https://developers.google.com/maps/documentation/solar/usage-and-billing). # The modelling_e2e fleet runs up to `maximum_concurrency` (32) containers, # each looping its ~50-property batch sequentially — so without pacing the # fleet offers multiples of the quota and a steady fraction of calls 429 # for a sustained window that #1327's jittered backoff can't ride out. # Each container holding >= quota/N seconds between its OWN consecutive # calls keeps the fleet sum under the ceiling. Default 0.0 = no throttle, # so the interactive backend path and tests are unchanged; the scaled # caller (the Lambda) opts in via env. self._api_key = api_key self._min_request_interval_seconds = min_request_interval_seconds self._last_request_monotonic: Optional[float] = None def _await_rate_limit(self) -> None: """Block until at least ``min_request_interval_seconds`` has elapsed since this instance's previous call, so the container never exceeds its share of the fleet's 600 QPM Solar budget. A no-op when the interval is 0 (the default / interactive path). The call's own latency counts toward the gap (we sleep only the *remainder*), so we don't double-pay the request time.""" if self._min_request_interval_seconds <= 0: return now = time.monotonic() if self._last_request_monotonic is not None: wait = self._min_request_interval_seconds - ( now - self._last_request_monotonic ) if wait > 0: time.sleep(wait) now = time.monotonic() self._last_request_monotonic = now @staticmethod def _parse_retry_after(response: requests.Response) -> Optional[float]: header = response.headers.get("Retry-After") if header is None: return None try: return float(header) except (TypeError, ValueError): return None def get_building_insights( self, longitude: float, latitude: float, required_quality: Literal["HIGH", "MEDIUM", "LOW"] = "MEDIUM", ) -> dict[str, Any]: self._await_rate_limit() 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, } def _fetch() -> dict[str, Any]: 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: response = e.response if response is None: # No response = a transport-level failure (connection reset / # read or connect timeout) — transient, retry. raise TransientHttpError(f"Solar API transport error: {e}") from e status = response.status_code if ( status == 404 and response.json().get("error", {}).get("message") == self.ENTITY_NOT_FOUND_ERROR ): raise BuildingInsightsNotFoundError() from e if status == 429 or status >= 500: # Rate-limit (429) and server errors (5xx) are transient. # Honour the server's Retry-After when present (jittered # exponential backoff otherwise — see `call_with_retry`). raise TransientHttpError( f"Solar API {status}", retry_after=self._parse_retry_after(response), ) from e # Other 4xx are the caller's fault — non-transient, propagate. raise return call_with_retry( _fetch, max_retries=self.MAX_RETRIES, max_backoff=self.MAX_BACKOFF_SECONDS, jitter=True, )