mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
120 lines
5.2 KiB
Python
120 lines
5.2 KiB
Python
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,
|
|
)
|