Retry interrupted thematic WCS tiles
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-16 04:46:13 +02:00
parent 6a7fa57463
commit 791548e332
2 changed files with 87 additions and 24 deletions
@@ -6,6 +6,7 @@ import math
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from http.client import HTTPException
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
@@ -55,6 +56,8 @@ class ThematicRasterAcquisitionService:
NODATA = -9999.0 NODATA = -9999.0
WCS_TILE_SIDE_M = 10_000.0 WCS_TILE_SIDE_M = 10_000.0
WCS_REQUEST_INTERVAL_SECONDS = 0.5 WCS_REQUEST_INTERVAL_SECONDS = 0.5
WCS_FETCH_ATTEMPTS = 3
WCS_RETRY_DELAY_SECONDS = 1.0
ATTRIBUTION = "Bron: Departement Omgeving, MercatorNet" ATTRIBUTION = "Bron: Departement Omgeving, MercatorNet"
LICENSE_NOTE = "Publieke GDI-Vlaanderen bron; bronvermelding en productspecifieke gebruiksvoorwaarden blijven van toepassing." LICENSE_NOTE = "Publieke GDI-Vlaanderen bron; bronvermelding en productspecifieke gebruiksvoorwaarden blijven van toepassing."
@@ -305,30 +308,38 @@ class ThematicRasterAcquisitionService:
def _fetch(request_url: str, settings: Settings, opener: Callable[..., Any] | None = None) -> tuple[bytes, str]: def _fetch(request_url: str, settings: Settings, opener: Callable[..., Any] | None = None) -> tuple[bytes, str]:
request = Request(request_url, headers={"Accept": "image/tiff,*/*", "User-Agent": "GeoIntel/0.1 bounded-thematic-raster"}) request = Request(request_url, headers={"Accept": "image/tiff,*/*", "User-Agent": "GeoIntel/0.1 bounded-thematic-raster"})
max_bytes = settings.thematic_raster_max_response_mb * 1024 * 1024 max_bytes = settings.thematic_raster_max_response_mb * 1024 * 1024
try: for attempt in range(1, ThematicRasterAcquisitionService.WCS_FETCH_ATTEMPTS + 1):
with (opener or urlopen)(request, timeout=settings.thematic_raster_timeout_seconds) as response: try:
content_type = str(response.headers.get("Content-Type", "")) with (opener or urlopen)(request, timeout=settings.thematic_raster_timeout_seconds) as response:
content_length = response.headers.get("Content-Length") content_type = str(response.headers.get("Content-Type", ""))
if content_length and int(content_length) > max_bytes: content_length = response.headers.get("Content-Length")
raise AppError(code="THEMATIC_RASTER_RESPONSE_TOO_LARGE", message="Official raster response exceeds the configured size limit", status_code=502) if content_length and int(content_length) > max_bytes:
content = response.read(max_bytes + 1) raise AppError(code="THEMATIC_RASTER_RESPONSE_TOO_LARGE", message="Official raster response exceeds the configured size limit", status_code=502)
except AppError: content = response.read(max_bytes + 1)
raise break
except HTTPError as exc: except AppError:
preview = exc.read(300).decode("utf-8", errors="replace") raise
raise AppError( except HTTPError as exc:
code="THEMATIC_RASTER_PROVIDER_UNAVAILABLE", preview = exc.read(300).decode("utf-8", errors="replace")
message="The official MercatorNet WCS could not complete the bounded request", if attempt < ThematicRasterAcquisitionService.WCS_FETCH_ATTEMPTS and int(exc.code) in {429, 500, 502, 503, 504}:
details={"reason": str(exc), "provider_status_code": int(exc.code), "response_preview": preview}, time.sleep(ThematicRasterAcquisitionService.WCS_RETRY_DELAY_SECONDS * attempt)
status_code=502, continue
) from exc raise AppError(
except (URLError, TimeoutError, OSError) as exc: code="THEMATIC_RASTER_PROVIDER_UNAVAILABLE",
raise AppError( message="The official MercatorNet WCS could not complete the bounded request",
code="THEMATIC_RASTER_PROVIDER_UNAVAILABLE", details={"reason": str(exc), "provider_status_code": int(exc.code), "response_preview": preview, "attempts": attempt},
message="The official MercatorNet WCS could not complete the bounded request", status_code=502,
details={"reason": str(exc)}, ) from exc
status_code=502, except (URLError, TimeoutError, OSError, HTTPException) as exc:
) from exc if attempt < ThematicRasterAcquisitionService.WCS_FETCH_ATTEMPTS:
time.sleep(ThematicRasterAcquisitionService.WCS_RETRY_DELAY_SECONDS * attempt)
continue
raise AppError(
code="THEMATIC_RASTER_PROVIDER_UNAVAILABLE",
message="The official MercatorNet WCS could not complete the bounded request",
details={"reason": str(exc), "attempts": attempt},
status_code=502,
) from exc
if len(content) > max_bytes: if len(content) > max_bytes:
raise AppError(code="THEMATIC_RASTER_RESPONSE_TOO_LARGE", message="Official raster response exceeds the configured size limit", status_code=502) raise AppError(code="THEMATIC_RASTER_RESPONSE_TOO_LARGE", message="Official raster response exceeds the configured size limit", status_code=502)
if not content.startswith((b"II*\x00", b"MM\x00*")): if not content.startswith((b"II*\x00", b"MM\x00*")):
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from http.client import IncompleteRead
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from uuid import uuid4 from uuid import uuid4
@@ -89,6 +90,11 @@ class FakeResponse:
return self.content[:limit] return self.content[:limit]
class IncompleteResponse(FakeResponse):
def read(self, limit: int):
raise IncompleteRead(self.content[:limit])
def payload(product_key: str = "space_occupation_2025", *, side_m: float = 1000.0) -> ThematicRasterAcquireRequest: def payload(product_key: str = "space_occupation_2025", *, side_m: float = 1000.0) -> ThematicRasterAcquireRequest:
transformer = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True) transformer = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True)
min_x, min_y = transformer.transform(200_000, 210_000) min_x, min_y = transformer.transform(200_000, 210_000)
@@ -171,6 +177,52 @@ def test_complete_kempen_scope_fits_the_tiled_thematic_guardrails() -> None:
assert exc_info.value.code == "THEMATIC_RASTER_SELECTION_TOO_LARGE" assert exc_info.value.code == "THEMATIC_RASTER_SELECTION_TOO_LARGE"
def test_wcs_fetch_retries_an_incomplete_tile_without_accepting_partial_bytes(monkeypatch) -> None:
content = b"II*\x00complete-geotiff"
responses = [IncompleteResponse(content), FakeResponse(content)]
attempts = 0
def opener(*_args, **_kwargs):
nonlocal attempts
response = responses[attempts]
attempts += 1
return response
monkeypatch.setattr("app.services.thematic_raster_acquisition_service.time.sleep", lambda _seconds: None)
result, content_type = ThematicRasterAcquisitionService._fetch(
"https://example.invalid/wcs",
Settings(_env_file=None),
opener,
)
assert attempts == 2
assert result == content
assert content_type == "image/tiff"
def test_wcs_fetch_fails_closed_after_bounded_incomplete_tile_retries(monkeypatch) -> None:
attempts = 0
def opener(*_args, **_kwargs):
nonlocal attempts
attempts += 1
return IncompleteResponse(b"II*\x00partial")
monkeypatch.setattr("app.services.thematic_raster_acquisition_service.time.sleep", lambda _seconds: None)
with pytest.raises(AppError) as exc_info:
ThematicRasterAcquisitionService._fetch(
"https://example.invalid/wcs",
Settings(_env_file=None),
opener,
)
assert attempts == ThematicRasterAcquisitionService.WCS_FETCH_ATTEMPTS
assert exc_info.value.code == "THEMATIC_RASTER_PROVIDER_UNAVAILABLE"
assert exc_info.value.details["attempts"] == 3
def test_binary_and_normalized_products_fail_closed_on_invalid_values() -> None: def test_binary_and_normalized_products_fail_closed_on_invalid_values() -> None:
binary = ThematicRasterAcquisitionService._product("space_occupation_2025") binary = ThematicRasterAcquisitionService._product("space_occupation_2025")
score = ThematicRasterAcquisitionService._product("service_level_2022") score = ThematicRasterAcquisitionService._product("service_level_2022")