From 943ecfbc90a7ebbb058bfe55953eb8ec26654244 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 22:23:22 +0200 Subject: [PATCH] Respect VMM flood coverage size limit --- .../flood_hazard_acquisition_service.py | 24 +++++++++++++-- .../tests/test_sprint208_vmm_flood_hazard.py | 29 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/backend/app/services/flood_hazard_acquisition_service.py b/backend/app/services/flood_hazard_acquisition_service.py index 59a802b5..13846b5e 100644 --- a/backend/app/services/flood_hazard_acquisition_service.py +++ b/backend/app/services/flood_hazard_acquisition_service.py @@ -14,6 +14,7 @@ from urllib.error import HTTPError, URLError from urllib.parse import urlencode from urllib.request import Request, urlopen from uuid import UUID +from xml.etree import ElementTree from geoalchemy2.shape import to_shape from pyproj import Transformer @@ -50,7 +51,9 @@ class FloodHazardAcquisitionService: SOURCE_VERSION = "VMM OGRK flood hazard maps" ATTRIBUTION = "Bron: VMM" LICENSE_NOTE = "Publieke toegang; gebruik en bronvermelding volgens de metadata van VMM/GDI-Vlaanderen." - WCS_TILE_SIDE_M = 10_000.0 + # The VMM WCS rejects generated coverages above 4.88 MB. At the default + # 5 metre resolution a 5 km square stays below that provider-side limit. + WCS_TILE_SIDE_M = 5_000.0 WCS_REQUEST_INTERVAL_SECONDS = 1.0 WCS_RETRY_DELAY_SECONDS = 3.0 WCS_TRANSIENT_STATUS_CODES = frozenset({400, 429, 502, 503, 504}) @@ -281,10 +284,27 @@ class FloodHazardAcquisitionService: if content.startswith((b"II*\x00", b"MM\x00*")): return content if "multipart" not in content_type.lower(): + provider_exception = None + if "xml" in content_type.lower() or content.lstrip().startswith(b"<"): + try: + root = ElementTree.fromstring(content) + exception_texts = [ + (element.text or "").strip() + for element in root.iter() + if element.tag.rsplit("}", 1)[-1] in {"ExceptionText", "ServiceException"} + and (element.text or "").strip() + ] + provider_exception = " ".join(exception_texts) or None + except ElementTree.ParseError: + pass raise AppError( code="FLOOD_HAZARD_PROVIDER_INVALID_RESPONSE", message="The official VMM service did not return a GeoTIFF coverage", - details={"content_type": content_type, "response_preview": content[:300].decode("utf-8", errors="replace")}, + details={ + "content_type": content_type, + "provider_exception": provider_exception, + "response_preview": content[:300].decode("utf-8", errors="replace"), + }, status_code=502, ) message = BytesParser(policy=default).parsebytes(f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode() + content) diff --git a/backend/tests/test_sprint208_vmm_flood_hazard.py b/backend/tests/test_sprint208_vmm_flood_hazard.py index 09a82d6d..7473c402 100644 --- a/backend/tests/test_sprint208_vmm_flood_hazard.py +++ b/backend/tests/test_sprint208_vmm_flood_hazard.py @@ -137,6 +137,35 @@ def test_flood_hazard_request_is_bounded_and_rejects_arbitrary_products() -> Non assert exc_info.value.code == "FLOOD_HAZARD_PRODUCT_NOT_SUPPORTED" +def test_flood_hazard_tiles_stay_below_the_observed_vmm_coverage_limit() -> None: + prepared = FloodHazardAcquisitionService._prepared_request(flood_payload(side_m=15_000), Settings(_env_file=None)) + tiles = FloodHazardAcquisitionService._tile_bounds(prepared) + + assert 9 <= len(tiles) <= 16 + assert all((max_x - min_x) <= 5_000 for min_x, _min_y, max_x, _max_y in tiles) + assert all((max_y - min_y) <= 5_000 for _min_x, min_y, _max_x, max_y in tiles) + assert all( + ((max_x - min_x) / prepared["resolution_m"]) * ((max_y - min_y) / prepared["resolution_m"]) + <= 1_000_000 + for min_x, min_y, max_x, max_y in tiles + ) + + +def test_flood_hazard_xml_provider_error_is_exposed_without_losing_the_canonical_error() -> None: + response = b""" + + + This request is trying to generate too much data + + """ + + with pytest.raises(AppError) as exc_info: + FloodHazardAcquisitionService._extract_geotiff(response, "application/xml") + + assert exc_info.value.code == "FLOOD_HAZARD_PROVIDER_INVALID_RESPONSE" + assert exc_info.value.details["provider_exception"] == "This request is trying to generate too much data" + + def test_flood_hazard_normalization_converts_centimetres_and_clips_zero_values() -> None: payload = flood_payload() prepared = FloodHazardAcquisitionService._prepared_request(payload, Settings(_env_file=None))