gendesign/backend/app/services/site_finder/osrm_client_local.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

154 lines
8.3 KiB
Python
Raw Permalink 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.

"""Тонкий клиент локального OSRM — реальное дорожное расстояние для /analyze (#39 A2).
В отличие от ORS (`ors_client.py`, внешний SaaS с лимитом 2000/день и /matrix POST),
это self-hosted OSRM (docker-compose service `osrm`, `settings.osrm_local_url`). Его
table-API — это **GET** с координатами в URL и `annotations=distance`, отдающий
**реальное дорожное расстояние в МЕТРАХ** (а не durations×скорость — это была бы
ORS-семантика):
GET {url}/table/v1/driving/{lon0},{lat0};{lon1},{lat1};...?sources=0&annotations=distance
{"code":"Ok","distances":[[0, d1, d2, ...]]} # метры; row[0] = origin→каждый dest
origin = центроид участка (первая координата, sources=0); destinations = POI.
Читаем `distances[0][1:]` (пропускаем self origin→origin = 0). Недостижимый POI =
`null` → возвращаем None по этому индексу (caller оставит straight-line).
Graceful: при ЛЮБОМ сбое (HTTP, timeout, code!=Ok, битая форма) → `OsrmLocalUnavailableError`.
Caller обязан поймать и сделать fallback на straight-line, НЕ валя /analyze.
OSRM запускается с `--max-table-size 8000`, поэтому ≤100 POI на analyze проходят
без усечения.
"""
from __future__ import annotations
import logging
from urllib.parse import quote
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
# OSRM `--max-table-size` (число пар sources+destinations). Дефолт сервиса — 8000;
# с 1 origin это 7999 POI, что с запасом покрывает наши ≤100. Держим как guard.
MAX_TABLE_SIZE = 8000
class OsrmLocalUnavailableError(Exception):
"""Локальный OSRM недоступен (сетевой сбой / timeout / code!=Ok / битый ответ).
Caller обязан поймать и сделать fallback (straight-line), не валя /analyze.
"""
def _fmt_coord(lon: float, lat: float) -> str:
"""OSRM-координата `lon,lat`, URL-safe (на случай экзотических локалей/инпута)."""
return f"{quote(str(lon), safe='')},{quote(str(lat), safe='')}"
def get_road_distances_m(
origin_lon: float,
origin_lat: float,
destinations: list[tuple[float, float]],
*,
profile: str = "driving",
timeout_s: float | None = None,
base_url: str | None = None,
) -> list[float | None]:
"""Дорожное расстояние (метры) от одного origin до каждого destination через OSRM /table.
Args:
origin_lon, origin_lat: координаты источника (центроид участка) = sources=0.
destinations: список (lon, lat) — POI. ≤MAX_TABLE_SIZE-1 (для нас всегда).
profile: OSRM-профиль ('driving' по умолчанию; 'foot' для пешего графа).
timeout_s: httpx timeout (обязателен — иначе worker зависает).
None → settings.osrm_distance_timeout_s.
base_url: базовый URL OSRM-сервиса. None → settings.osrm_local_url
(DRIVING-граф, поведение по умолчанию). Передаётся, например,
settings.osrm_walk_local_url для маршрутизации walk-POI по FOOT-графу
(#39 A3).
Returns:
Список той же длины, что `destinations`, выровненный по индексу. Элемент —
дорожное расстояние в метрах (float) или None, если OSRM не смог построить
маршрут до этой точки (POI на острове/без дорожной связи) — caller оставит
straight-line по этому POI. Пустой `destinations` → [].
Raises:
OsrmLocalUnavailableError: сетевой сбой / timeout / HTTP!=2xx / code!=Ok /
неожиданный формат / число расстояний != числу destinations. Caller
делает straight-line fallback, /analyze не падает.
"""
if not destinations:
return []
if len(destinations) > MAX_TABLE_SIZE - 1:
# OSRM отвергнет таблицу больше max-table-size — считаем это «недоступностью»
# для данного запроса (caller → straight-line по всем POI).
raise OsrmLocalUnavailableError(
f"OSRM table: {len(destinations)} destinations > max-table-size "
f"({MAX_TABLE_SIZE - 1} с одним origin)"
)
effective_timeout = timeout_s if timeout_s is not None else settings.osrm_distance_timeout_s
# Координаты `;`-joined: origin первой (→ sources=0), затем POI по порядку.
coords = ";".join(
[_fmt_coord(origin_lon, origin_lat)] + [_fmt_coord(lon, lat) for lon, lat in destinations]
)
base = (base_url if base_url is not None else settings.osrm_local_url).rstrip("/")
url = f"{base}/table/v1/{profile}/{coords}"
params = {"sources": "0", "annotations": "distance"}
try:
with httpx.Client(timeout=effective_timeout) as client:
resp = client.get(url, params=params)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as exc:
raise OsrmLocalUnavailableError(
f"OSRM table HTTP {exc.response.status_code}: {exc.response.text[:200]}"
) from exc
except Exception as exc: # сетевой сбой / timeout / JSON decode → fallback, не 500
raise OsrmLocalUnavailableError(f"OSRM table request failed: {exc}") from exc
if not isinstance(data, dict) or data.get("code") != "Ok":
code = data.get("code") if isinstance(data, dict) else "<non-dict>"
raise OsrmLocalUnavailableError(f"OSRM table: code != Ok ({code!r})")
# Ответ: {"distances": [[0, d1, d2, ...]]} — метры, одна строка (один source).
distances = data.get("distances")
if not isinstance(distances, list) or not distances:
raise OsrmLocalUnavailableError("OSRM table: пустой/битый distances в ответе")
row = distances[0]
if not isinstance(row, list) or not row:
raise OsrmLocalUnavailableError("OSRM table: distances[0] не список / пуст")
# row[0] = origin→origin = 0 (self) — пропускаем, читаем 1:.
dest_distances = row[1:]
out: list[float | None] = []
for d in dest_distances:
if d is None:
out.append(None) # OSRM не построил маршрут до этой точки
else:
# #2464: конверсия обязана падать в ДОМЕННУЮ ошибку. Весь файл переводит
# любую кривизну ответа в OsrmLocalUnavailableError (строки выше), потому
# что вызывающий (parcels.py:399) ловит ТОЛЬКО её и уходит на прямолинейный
# fallback. Голый float() на нечисловом значении поднял бы ValueError или
# TypeError — они пролетят мимо и дадут 500 на /analyze вместо деградации.
try:
out.append(float(d))
except (TypeError, ValueError) as exc:
raise OsrmLocalUnavailableError(
f"OSRM table: нечисловое расстояние {d!r} в distances"
) from exc
# Длина должна совпадать с числом destinations — иначе zip у вызывающего
# рассинхронит POI↔distance. Закрываем как OSRM-сбой → straight-line fallback.
if len(out) != len(destinations):
raise OsrmLocalUnavailableError(
f"OSRM table: distances ({len(out)}) != destinations ({len(destinations)})"
)
return out