Respect VMM flood coverage size limit
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user