Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM ghcr.io/astral-sh/uv:python3.14-alpine
FROM ghcr.io/astral-sh/uv:0.11.7-python3.14-alpine AS base

WORKDIR /app
ENV PYTHONPATH=/app
Expand All @@ -10,4 +10,4 @@ RUN uv sync --no-install-project

EXPOSE 8000

CMD ["uv", "run", "uvicorn", "drillapi.app:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uv", "run", "uvicorn", "drillapi.app:app", "--host", "0.0.0.0", "--port", "8000"]
12 changes: 1 addition & 11 deletions Dockerfile.lambda
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# from https://docs.astral.sh/uv/guides/integration/aws-lambda/#deploying-a-docker-image

FROM ghcr.io/astral-sh/uv:0.11.6 AS uv
FROM ghcr.io/astral-sh/uv:0.11.7 AS uv

# First, bundle the dependencies into the task root.
FROM public.ecr.aws/lambda/python:3.14 AS builder
Expand Down Expand Up @@ -28,16 +28,6 @@ RUN --mount=from=uv,source=/uv,target=/bin/uv \

FROM public.ecr.aws/lambda/python:3.14

# Patch OS-level vulnerabilities (openssl, aws-lambda-rie).
# fix CVE-2026-2673.
RUN dnf upgrade -y openssl-libs openssl-fips-provider-latest && \
dnf clean all && \
rm -rf /var/cache/dnf

# Update aws-lambda-rie to latest release to fix CVE-2026-32280.
ADD https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie-x86_64 /usr/local/bin/aws-lambda-rie
RUN chmod 755 /usr/local/bin/aws-lambda-rie

# Copy the runtime dependencies from the builder stage.
COPY --from=builder ${LAMBDA_TASK_ROOT} ${LAMBDA_TASK_ROOT}

Expand Down
1 change: 1 addition & 0 deletions src/drillapi/models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class GroundSuitability(IntEnum):
UNKNOWN = 4
NOT_AVAILABLE = 5
NOT_IN_SWITZERLAND = 6
GEOSERVICE_UNAVAILABLE = 98
PROBLEM = 99


Expand Down
21 changes: 20 additions & 1 deletion src/drillapi/routes/drill_category.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
from ..services import processing, security
from ..services.error_handler import handle_errors
from ..config import settings
from ..models.models import SuitabilityFeature, GroundCategory, ResultDetail
from ..models.models import (
SuitabilityFeature,
GroundCategory,
GroundSuitability,
ResultDetail,
)
import logging

router = APIRouter()
Expand Down Expand Up @@ -75,6 +80,20 @@ async def get_drill_category(
# Fetch features (WMS or ESRI REST) from external geoservices and process into feature
result = await processing.fetch_features_for_point(coord_x, coord_y, canton_config)

# Handle external geoservice unavailability
if result.get("geoservice_unavailable"):
suitability_feature.ground_category.harmonized_value = (
GroundSuitability.GEOSERVICE_UNAVAILABLE
)
suitability_feature.ground_category.source_values = "geoservice unavailable"
# Keep canton_config so the frontend can access cantonal_energy_service_url
suitability_feature.result_detail = ResultDetail(
message="External geoservice unavailable",
full_url=result.get("full_url", ""),
detail=result.get("error"),
)
return suitability_feature

# Feature(s) found, process to reclassification
processed_ground_category = processing.process_ground_category(
result["features"],
Expand Down
34 changes: 23 additions & 11 deletions src/drillapi/services/processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,13 @@ async def fetch_features_for_point(coord_x: float, coord_y: float, config: dict)
features = data.get("features") or []
except Exception as e:
error_message = f"WMS request failed: {e}"
logger.error("%s — URL: %s", error_message, full_url)
raise HTTPException(
status_code=502, detail=f"{error_message} — URL: {full_url}"
)
logger.error("%s — URL: %s", error_message, full_url or esri_url)
return {
"features": [],
"full_url": full_url or esri_url,
"error": error_message,
"geoservice_unavailable": True,
}

# WMS GetFeatureInfo
else:
Expand Down Expand Up @@ -161,10 +164,13 @@ async def fetch_features_for_point(coord_x: float, coord_y: float, config: dict)
resp.raise_for_status()
except Exception as e:
error_message = f"WMS request failed: {e}"
logger.error("%s — URL: %s", error_message, full_url)
raise HTTPException(
status_code=502, detail=f"{error_message} — URL: {full_url}"
)
logger.error("%s — URL: %s", error_message, full_url or query_url)
return {
"features": [],
"full_url": full_url or query_url,
"error": error_message,
"geoservice_unavailable": True,
}

try:
features = parse_wms_getfeatureinfo(
Expand All @@ -177,12 +183,18 @@ async def fetch_features_for_point(coord_x: float, coord_y: float, config: dict)
"error": error_message,
}

except HTTPException:
# Re-raise genuine internal errors (e.g., invalid JSON/XML from parse_wms_getfeatureinfo)
raise
except Exception as e:
error_message = f"Failed to parse WMS or ESRI REST response: {e}"
logger.error("%s — URL: %s", error_message, full_url)
raise HTTPException(
status_code=502, detail=f"{error_message} — URL: {full_url}"
)
return {
"features": [],
"full_url": full_url,
"error": error_message,
"geoservice_unavailable": True,
}


# PARSE WMS or REST responses
Expand Down
167 changes: 167 additions & 0 deletions tests/test_geoservice_unavailable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""Tests for geoservice unavailability handling.

Verifies that when external cantonal geoservices fail (timeout, connection error,
invalid response), the backend returns a structured HTTP 200 response with
harmonized_value=98 and the canton identifier, instead of raising HTTPException(502).
Canton config is preserved so the frontend can access cantonal_energy_service_url.
"""

import pytest
import respx
import httpx
from fastapi.testclient import TestClient
from drillapi.app import app


@pytest.fixture
def client():
with TestClient(app) as c:
yield c


def _mock_canton_identify(canton_code: str):
"""Mock the geo.admin.ch canton identification endpoint."""
canton_response = {
"results": [
{
"featureId": 1,
"layerBodId": "ch.swisstopo.swissboundaries3d-kanton-flaeche.fill",
"layerName": "Cantonal boundaries",
"id": 1,
"attributes": {"ak": canton_code},
}
]
}
respx.get(
"https://api3.geo.admin.ch/rest/services/ech/MapServer/identify", params=None
).mock(
return_value=httpx.Response(
200,
json=canton_response,
headers={"Content-Type": "application/json"},
)
)


@respx.mock
def test_wms_timeout_returns_structured_response(client):
"""When a WMS geoservice times out, return HTTP 200 with harmonized_value=98."""
coord_x = 2574738
coord_y = 1249285

_mock_canton_identify("JU")

respx.get("https://geoservices.jura.ch/wms", params=None).mock(
side_effect=httpx.ConnectTimeout("Connection timed out")
)

response = client.get(f"/v1/drill-category/{coord_x}/{coord_y}")

assert response.status_code == 200
payload = response.json()
assert payload["canton"] == "JU"
assert payload["ground_category"]["harmonized_value"] == 98
assert payload["ground_category"]["source_values"] == "geoservice unavailable"
assert payload["result_detail"]["message"] == "External geoservice unavailable"
assert payload["canton_config"] is not None
assert payload["canton_config"]["name"] == "JU"


@respx.mock
def test_esri_connection_error_returns_structured_response(client):
"""When an ESRI REST geoservice has a connection error, return HTTP 200 with harmonized_value=98."""
coord_x = 2582124
coord_y = 1164966

_mock_canton_identify("FR")

respx.get(
"https://map.geo.fr.ch/arcgis/rest/services/PortailCarto/Theme_environnement/MapServer/17/query",
params=None,
).mock(side_effect=httpx.ConnectError("Connection refused"))

response = client.get(f"/v1/drill-category/{coord_x}/{coord_y}")

assert response.status_code == 200
payload = response.json()
assert payload["canton"] == "FR"
assert payload["ground_category"]["harmonized_value"] == 98
assert payload["ground_category"]["source_values"] == "geoservice unavailable"
assert payload["result_detail"]["message"] == "External geoservice unavailable"
assert payload["canton_config"] is not None
assert payload["canton_config"]["name"] == "FR"


@respx.mock
def test_wms_http_500_returns_structured_response(client):
"""When a WMS geoservice returns HTTP 500, return HTTP 200 with harmonized_value=98."""
coord_x = 2574738
coord_y = 1249285

_mock_canton_identify("JU")

respx.get("https://geoservices.jura.ch/wms", params=None).mock(
return_value=httpx.Response(500, text="Internal Server Error")
)

response = client.get(f"/v1/drill-category/{coord_x}/{coord_y}")

assert response.status_code == 200
payload = response.json()
assert payload["canton"] == "JU"
assert payload["ground_category"]["harmonized_value"] == 98
assert payload["ground_category"]["source_values"] == "geoservice unavailable"
assert payload["canton_config"] is not None
assert payload["canton_config"]["name"] == "JU"


@respx.mock
def test_successful_wms_still_works(client):
"""Successful WMS responses should still return normal suitability values (preservation)."""
coord_x = 2574738
coord_y = 1249285

_mock_canton_identify("JU")

with open("tests/data/wms/getfeatureinfo_ju.gml", "rb") as f:
gml = f.read()

respx.get("https://geoservices.jura.ch/wms", params=None).mock(
return_value=httpx.Response(
200,
content=gml,
headers={"Content-Type": "application/vnd.ogc.gml"},
)
)

response = client.get(f"/v1/drill-category/{coord_x}/{coord_y}")

assert response.status_code == 200
payload = response.json()
assert payload["ground_category"]["harmonized_value"] == 1
assert payload["result_detail"]["message"] == "Success"


@respx.mock
def test_canton_preserved_in_geoservice_failure(client):
"""Canton identifier and config must be preserved in the response when geoservice fails."""
coord_x = 2574738
coord_y = 1249285

_mock_canton_identify("JU")

respx.get("https://geoservices.jura.ch/wms", params=None).mock(
side_effect=httpx.ReadTimeout("Read timed out")
)

response = client.get(f"/v1/drill-category/{coord_x}/{coord_y}")

assert response.status_code == 200
payload = response.json()
# Canton must be preserved
assert payload["canton"] == "JU"
# Canton config preserved so frontend can access cantonal_energy_service_url
assert payload["canton_config"] is not None
assert payload["canton_config"]["name"] == "JU"
assert payload["canton_config"]["cantonal_energy_service_url"] is not None
assert payload["ground_category"]["harmonized_value"] == 98
Loading