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,6 +308,7 @@ 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
for attempt in range(1, ThematicRasterAcquisitionService.WCS_FETCH_ATTEMPTS + 1):
try: try:
with (opener or urlopen)(request, timeout=settings.thematic_raster_timeout_seconds) as response: with (opener or urlopen)(request, timeout=settings.thematic_raster_timeout_seconds) as response:
content_type = str(response.headers.get("Content-Type", "")) content_type = str(response.headers.get("Content-Type", ""))
@@ -312,21 +316,28 @@ class ThematicRasterAcquisitionService:
if content_length and int(content_length) > max_bytes: if content_length and int(content_length) > 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)
content = response.read(max_bytes + 1) content = response.read(max_bytes + 1)
break
except AppError: except AppError:
raise raise
except HTTPError as exc: except HTTPError as exc:
preview = exc.read(300).decode("utf-8", errors="replace") preview = exc.read(300).decode("utf-8", errors="replace")
if attempt < ThematicRasterAcquisitionService.WCS_FETCH_ATTEMPTS and int(exc.code) in {429, 500, 502, 503, 504}:
time.sleep(ThematicRasterAcquisitionService.WCS_RETRY_DELAY_SECONDS * attempt)
continue
raise AppError( raise AppError(
code="THEMATIC_RASTER_PROVIDER_UNAVAILABLE", code="THEMATIC_RASTER_PROVIDER_UNAVAILABLE",
message="The official MercatorNet WCS could not complete the bounded request", message="The official MercatorNet WCS could not complete the bounded request",
details={"reason": str(exc), "provider_status_code": int(exc.code), "response_preview": preview}, details={"reason": str(exc), "provider_status_code": int(exc.code), "response_preview": preview, "attempts": attempt},
status_code=502, status_code=502,
) from exc ) from exc
except (URLError, TimeoutError, OSError) as exc: except (URLError, TimeoutError, OSError, HTTPException) as exc:
if attempt < ThematicRasterAcquisitionService.WCS_FETCH_ATTEMPTS:
time.sleep(ThematicRasterAcquisitionService.WCS_RETRY_DELAY_SECONDS * attempt)
continue
raise AppError( raise AppError(
code="THEMATIC_RASTER_PROVIDER_UNAVAILABLE", code="THEMATIC_RASTER_PROVIDER_UNAVAILABLE",
message="The official MercatorNet WCS could not complete the bounded request", message="The official MercatorNet WCS could not complete the bounded request",
details={"reason": str(exc)}, details={"reason": str(exc), "attempts": attempt},
status_code=502, status_code=502,
) from exc ) from exc
if len(content) > max_bytes: if len(content) > max_bytes:
@@ -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")