Respect VMM flood coverage size limit
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-15 22:23:22 +02:00
parent 501f824257
commit 943ecfbc90
2 changed files with 51 additions and 2 deletions
@@ -14,6 +14,7 @@ from urllib.error import HTTPError, URLError
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from uuid import UUID from uuid import UUID
from xml.etree import ElementTree
from geoalchemy2.shape import to_shape from geoalchemy2.shape import to_shape
from pyproj import Transformer from pyproj import Transformer
@@ -50,7 +51,9 @@ class FloodHazardAcquisitionService:
SOURCE_VERSION = "VMM OGRK flood hazard maps" SOURCE_VERSION = "VMM OGRK flood hazard maps"
ATTRIBUTION = "Bron: VMM" ATTRIBUTION = "Bron: VMM"
LICENSE_NOTE = "Publieke toegang; gebruik en bronvermelding volgens de metadata van VMM/GDI-Vlaanderen." 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_REQUEST_INTERVAL_SECONDS = 1.0
WCS_RETRY_DELAY_SECONDS = 3.0 WCS_RETRY_DELAY_SECONDS = 3.0
WCS_TRANSIENT_STATUS_CODES = frozenset({400, 429, 502, 503, 504}) 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*")): if content.startswith((b"II*\x00", b"MM\x00*")):
return content return content
if "multipart" not in content_type.lower(): 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( raise AppError(
code="FLOOD_HAZARD_PROVIDER_INVALID_RESPONSE", code="FLOOD_HAZARD_PROVIDER_INVALID_RESPONSE",
message="The official VMM service did not return a GeoTIFF coverage", 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, status_code=502,
) )
message = BytesParser(policy=default).parsebytes(f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode() + content) message = BytesParser(policy=default).parsebytes(f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode() + content)
@@ -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" 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"""<?xml version="1.0"?>
<ExceptionReport xmlns="http://www.opengis.net/ows/1.1">
<Exception exceptionCode="NoApplicableCode">
<ExceptionText>This request is trying to generate too much data</ExceptionText>
</Exception>
</ExceptionReport>"""
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: def test_flood_hazard_normalization_converts_centimetres_and_clips_zero_values() -> None:
payload = flood_payload() payload = flood_payload()
prepared = FloodHazardAcquisitionService._prepared_request(payload, Settings(_env_file=None)) prepared = FloodHazardAcquisitionService._prepared_request(payload, Settings(_env_file=None))