gendesign/backend/app/services/site_finder/ors_client.py
bot-backend 56868f2bde
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m16s
Deploy / build-worker (push) Successful in 6m44s
Deploy / deploy (push) Successful in 1m31s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 9s
fix(ptica): нечисловое значение от OSRM/ORS больше не даёт 500 вместо деградации (#2464) (#2944)
2026-08-19 17:20:28 +00:00

145 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тонкий клиент OpenRouteService (ORS) — переиспользуемый между фичами.
Сейчас ORS используется для:
- изохрон доступности (`/parcels/{cad}/isochrones`, см. parcels.get_isochrones)
- routing-based POI decay (#41, см. poi_score.compute_poi_routing_decay) — `/matrix`
Free tier: 2000 запросов/день. Поэтому:
- один HTTP-вызов на analyze (батч до ~1000 destinations в /matrix)
- явный httpx timeout (без него worker зависает)
- graceful: нет API-ключа / 429 / сетевой сбой → `OrsUnavailableError`, caller делает
fallback на straight-line, а не валит analyze.
"""
from __future__ import annotations
import logging
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
_ORS_MATRIX_BASE = "https://api.openrouteservice.org/v2/matrix"
# Профили ORS-routing. foot-walking — пеший радиус (метро/школа/магазин),
# driving-car — авто (для будущих авто-категорий).
VALID_PROFILES: frozenset[str] = frozenset({"foot-walking", "cycling-regular", "driving-car"})
# ORS /matrix ограничивает foot-walking ~2000 пар (sources×destinations) на free tier.
MAX_MATRIX_DESTINATIONS = 1000
_DEFAULT_TIMEOUT_S = 12.0
class OrsUnavailableError(Exception):
"""ORS недоступен (нет ключа / rate-limit / сетевой сбой / битый ответ).
Caller обязан поймать и сделать fallback (straight-line), не валя запрос.
"""
def is_configured() -> bool:
"""True если задан API-ключ ORS (иначе сразу fallback без HTTP)."""
return bool(settings.openrouteservice_api_key)
def matrix_durations_min(
origin_lon: float,
origin_lat: float,
destinations: list[tuple[float, float]],
*,
profile: str = "foot-walking",
timeout_s: float = _DEFAULT_TIMEOUT_S,
) -> list[float | None]:
"""Время в пути (минуты) от одного origin до каждого destination через ORS /matrix.
Args:
origin_lon, origin_lat: координаты источника (центроид участка).
destinations: список (lon, lat) — POI. Усечётся до MAX_MATRIX_DESTINATIONS.
profile: ORS-профиль ('foot-walking' по умолчанию — пешая доступность).
timeout_s: httpx timeout (обязателен — иначе worker зависает).
Returns:
Список той же длины, что усечённый `destinations`. Элемент — минуты в пути
(float) или None, если ORS не смог построить маршрут до этой точки
(например, POI на острове без пешеходной связи).
Raises:
OrsUnavailableError: нет ключа / mode невалиден / rate-limit / сетевой сбой /
неожиданный формат ответа. Caller делает straight-line fallback.
"""
if not is_configured():
raise OrsUnavailableError("OPENROUTESERVICE_API_KEY не задан")
if profile not in VALID_PROFILES:
raise OrsUnavailableError(f"Недопустимый ORS profile: {profile!r}")
if not destinations:
return []
dests = destinations[:MAX_MATRIX_DESTINATIONS]
if len(destinations) > MAX_MATRIX_DESTINATIONS:
logger.warning(
"ors matrix: %d destinations усечено до %d (free-tier limit)",
len(destinations),
MAX_MATRIX_DESTINATIONS,
)
# ORS /matrix: locations[0] — source, остальные — destinations.
locations: list[list[float]] = [[origin_lon, origin_lat]]
locations.extend([lon, lat] for lon, lat in dests)
body = {
"locations": locations,
"sources": [0],
"destinations": list(range(1, len(locations))),
"metrics": ["duration"],
"units": "m",
}
headers = {
"Authorization": settings.openrouteservice_api_key,
"Content-Type": "application/json",
"Accept": "application/json",
}
url = f"{_ORS_MATRIX_BASE}/{profile}"
try:
with httpx.Client(timeout=timeout_s) as client:
resp = client.post(url, json=body, headers=headers)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 429:
raise OrsUnavailableError("ORS daily limit (2000 req) exceeded") from exc
raise OrsUnavailableError(
f"ORS matrix HTTP {exc.response.status_code}: {exc.response.text[:200]}"
) from exc
except Exception as exc: # сетевой сбой / JSON decode — fallback, не 500
raise OrsUnavailableError(f"ORS matrix request failed: {exc}") from exc
# Ответ: {"durations": [[d1, d2, ...]]} — секунды, одна строка (один source).
durations = data.get("durations")
if not isinstance(durations, list) or not durations:
raise OrsUnavailableError("ORS matrix: пустой/битый durations в ответе")
row = durations[0]
if not isinstance(row, list):
raise OrsUnavailableError("ORS matrix: durations[0] не список")
out: list[float | None] = []
for sec in row:
if sec is None:
out.append(None) # ORS не построил маршрут до этой точки
else:
# #2464: тот же довод, что у проверки длины ниже — нечисловое значение
# дало бы ValueError/TypeError мимо OrsUnavailableError, а вызывающий
# (poi_score.py:348) ловит только её. Принцип в этом файле уже
# сформулирован, просто не применён к самой конверсии.
try:
out.append(float(sec) / 60.0)
except (TypeError, ValueError) as exc:
raise OrsUnavailableError(
f"ORS matrix: нечисловая длительность {sec!r} в durations"
) from exc
# Длина durations должна совпадать с числом destinations — иначе zip(strict=True)
# у вызывающего бросит ValueError (не OrsUnavailableError) → 500. Закрываем как ORS-сбой.
if len(out) != len(dests):
raise OrsUnavailableError(
f"ORS matrix: durations ({len(out)}) != destinations ({len(dests)})"
)
return out