diff --git a/backend/app/services/site_finder/ors_client.py b/backend/app/services/site_finder/ors_client.py index 48594de0..1fc7d88e 100644 --- a/backend/app/services/site_finder/ors_client.py +++ b/backend/app/services/site_finder/ors_client.py @@ -24,9 +24,7 @@ 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"} -) +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 @@ -128,7 +126,16 @@ def matrix_durations_min( if sec is None: out.append(None) # ORS не построил маршрут до этой точки else: - out.append(float(sec) / 60.0) + # #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): diff --git a/backend/app/services/site_finder/osrm_client_local.py b/backend/app/services/site_finder/osrm_client_local.py index e50bb9b2..d8a1ca1e 100644 --- a/backend/app/services/site_finder/osrm_client_local.py +++ b/backend/app/services/site_finder/osrm_client_local.py @@ -96,8 +96,7 @@ def get_road_distances_m( # Координаты `;`-joined: origin первой (→ sources=0), затем POI по порядку. coords = ";".join( - [_fmt_coord(origin_lon, origin_lat)] - + [_fmt_coord(lon, lat) for lon, lat in destinations] + [_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}" @@ -134,7 +133,17 @@ def get_road_distances_m( if d is None: out.append(None) # OSRM не построил маршрут до этой точки else: - out.append(float(d)) + # #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. diff --git a/backend/tests/test_ors_client.py b/backend/tests/test_ors_client.py index 5f7f0a07..dc1a7d8a 100644 --- a/backend/tests/test_ors_client.py +++ b/backend/tests/test_ors_client.py @@ -121,3 +121,32 @@ def test_matrix_bad_response_raises(with_key, monkeypatch): _install_transport(monkeypatch, handler) with pytest.raises(ors_client.OrsUnavailableError): ors_client.matrix_durations_min(60.6, 56.8, [(60.61, 56.81)]) + + +def test_non_numeric_duration_degrades_instead_of_500(with_key, monkeypatch): + """#2464: нечисловая длительность → OrsUnavailableError, а не ValueError. + + Довод уже сформулирован в самом файле — у проверки длины ниже написано, что + ValueError «не OrsUnavailableError → 500». К самой конверсии принцип применён + не был: вызывающий (poi_score.py:348) ловит только доменную ошибку. + """ + + def handler(_request): + return httpx.Response(200, json={"durations": [[0.0, "не число"]]}) + + _install_transport(monkeypatch, handler) + + with pytest.raises(ors_client.OrsUnavailableError): + ors_client.matrix_durations_min(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)]) + + +def test_list_instead_of_duration_also_degrades(with_key, monkeypatch): + """TypeError тоже обязан стать доменной ошибкой, не только ValueError.""" + + def handler(_request): + return httpx.Response(200, json={"durations": [[0.0, [300]]]}) + + _install_transport(monkeypatch, handler) + + with pytest.raises(ors_client.OrsUnavailableError): + ors_client.matrix_durations_min(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)]) diff --git a/backend/tests/test_osrm_client_local.py b/backend/tests/test_osrm_client_local.py index fd53dab8..b67fc990 100644 --- a/backend/tests/test_osrm_client_local.py +++ b/backend/tests/test_osrm_client_local.py @@ -41,9 +41,7 @@ def test_builds_correct_url_and_parses_meters(monkeypatch): return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, 1234.5, 6789.0]]}) _install_transport(monkeypatch, handler) - out = osrm.get_road_distances_m( - 60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)] - ) + out = osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)]) # Дорожные расстояния (метры), self-index сброшен. assert out == [1234.5, 6789.0] @@ -164,3 +162,48 @@ def test_length_mismatch_raises_unavailable(monkeypatch): _install_transport(monkeypatch, handler) with pytest.raises(osrm.OsrmLocalUnavailableError, match="!= destinations"): osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)]) + + +def test_non_numeric_distance_degrades_instead_of_500(monkeypatch): + """#2464: нечисловое значение в distances → доменная ошибка, а не ValueError. + + Весь файл переводит любую кривизну ответа в OsrmLocalUnavailableError, потому что + вызывающий (parcels.py:399) ловит ТОЛЬКО её и уходит на прямолинейный fallback. + Голый `float(d)` был исключением из этого правила: ValueError/TypeError пролетели + бы мимо обработчика и дали 500 на /analyze вместо деградации. + """ + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, "не число", 10.0]]}) + + _install_transport(monkeypatch, handler) + + with pytest.raises(osrm.OsrmLocalUnavailableError): + osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)]) + + +def test_dict_instead_of_distance_also_degrades(monkeypatch): + """TypeError (не только ValueError) тоже обязан стать доменной ошибкой.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, {"m": 5}, 10.0]]}) + + _install_transport(monkeypatch, handler) + + with pytest.raises(osrm.OsrmLocalUnavailableError): + osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)]) + + +def test_null_distance_is_still_a_legitimate_none(monkeypatch): + """Контроль: null — это «маршрут не построен», а не поломка ответа. + + Зелёный с обеих сторон: правка не должна превращать законный None в ошибку. + """ + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, None, 10.0]]}) + + _install_transport(monkeypatch, handler) + out = osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)]) + + assert out == [None, 10.0]