Model/infrastructure/solar/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

101 lines
4.3 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
@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]:
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,
)