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
116 changes: 116 additions & 0 deletions backend/app/api/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
from __future__ import annotations

import uuid
from typing import Any

from fastapi import Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from starlette.middleware.base import BaseHTTPMiddleware

REQUEST_ID_HEADER = "X-Request-ID"


class APIError(BaseModel):
"""Public API error object shared by exception handlers and OpenAPI docs."""

status: int = Field(..., ge=400, le=599)
code: str = Field(..., min_length=1)
title: str = Field(..., min_length=1)
detail: str | None = None
request_id: str | None = None


class APIErrorResponse(BaseModel):
errors: list[APIError] = Field(..., min_length=1)


COMMON_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = {
500: {
"model": APIErrorResponse,
"description": "Internal server error",
},
}


class RequestIDMiddleware(BaseHTTPMiddleware):
"""Attach a stable request ID to every request and response."""

async def dispatch(self, request: Request, call_next):
request_id = request.headers.get(REQUEST_ID_HEADER) or uuid.uuid4().hex
request.state.request_id = request_id

response = await call_next(request)
response.headers[REQUEST_ID_HEADER] = request_id
return response


def get_request_id(request: Request | None) -> str | None:
if request is None:
return None

state = getattr(request, "state", None)
state_request_id = getattr(state, "request_id", None)
if state_request_id:
return str(state_request_id)

headers = getattr(request, "headers", None)
header_request_id = headers.get(REQUEST_ID_HEADER) if headers is not None else None
return header_request_id or None


def build_error_payload(
*,
status_code: int,
code: str,
title: str,
detail: str | None = None,
request_id: str | None = None,
) -> dict[str, Any]:
payload = APIErrorResponse(
errors=[
APIError(
status=status_code,
code=code,
title=title,
detail=detail,
request_id=request_id,
)
]
)
return payload.model_dump(exclude_none=True)


def api_error_response(
*,
status_code: int,
code: str,
title: str,
detail: str | None = None,
request_id: str | None = None,
headers: dict[str, str] | None = None,
) -> JSONResponse:
response = JSONResponse(
status_code=status_code,
content=build_error_payload(
status_code=status_code,
code=code,
title=title,
detail=detail,
request_id=request_id,
),
headers=headers,
)
if request_id:
response.headers[REQUEST_ID_HEADER] = request_id
return response


def internal_server_error_response(request: Request) -> JSONResponse:
return api_error_response(
status_code=500,
code="internal_server_error",
title="Internal server error",
detail="An unexpected error occurred.",
request_id=get_request_id(request),
)
51 changes: 42 additions & 9 deletions backend/app/api/v1/endpoint_modules/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from fastapi.responses import JSONResponse
from sqlalchemy import select

from app.api.errors import COMMON_ERROR_RESPONSES, api_error_response, get_request_id
from app.api.v1.advanced_search_utils import validate_adv_q
from app.api.v1.strong_params import FACET_ALLOWED_PARAMS
from app.api.v1.utils import (
Expand Down Expand Up @@ -58,6 +59,24 @@
# Cache TTL configuration in seconds
SEARCH_CACHE_TTL = int(3600) # 1 hour
SUGGEST_CACHE_TTL = int(7200) # 2 hours


def _search_error_response(
request: Request,
*,
code: str,
title: str = "Search failed",
detail: str = "Search request failed.",
) -> JSONResponse:
return api_error_response(
status_code=500,
code=code,
title=title,
detail=detail,
request_id=get_request_id(request),
)


SEARCH_RESULT_CACHE_TTL = int(os.getenv("SEARCH_RESULT_CACHE_TTL", str(SEARCH_CACHE_TTL)))
SEARCH_RESULT_CACHE_VERSION = os.getenv("SEARCH_RESULT_CACHE_VERSION", "v1")
SEARCH_RESULT_CACHE_NAMESPACE = "search.results"
Expand Down Expand Up @@ -463,7 +482,11 @@ async def _handle_search(request: Request, params: dict) -> JSONResponse:
search_duration_ms = (time.perf_counter() - search_started_at) * 1000
if isinstance(results, dict) and "error" in results:
logger.error("Search service returned an internal error", exc_info=False)
return JSONResponse(content={"error": "Elasticsearch search failed"}, status_code=500)
return _search_error_response(
request,
code="elasticsearch_search_failed",
detail="Elasticsearch search failed.",
)

# Step 2: Extract resource IDs and scores
result_obj = results if isinstance(results, dict) else {}
Expand Down Expand Up @@ -722,7 +745,7 @@ async def _handle_search(request: Request, params: dict) -> JSONResponse:
return _attach_search_timing_headers(response, timings)


@router.get("/search")
@router.get("/search", responses=COMMON_ERROR_RESPONSES)
@cached_endpoint(ttl=SEARCH_CACHE_TTL)
async def search(
request: Request,
Expand Down Expand Up @@ -826,10 +849,10 @@ async def search(
total_duration,
exc_info=True,
)
return JSONResponse(content={"error": "Search request failed"}, status_code=500)
return _search_error_response(request, code="search_request_failed")


@router.post("/search")
@router.post("/search", responses=COMMON_ERROR_RESPONSES)
@cached_endpoint(ttl=SEARCH_CACHE_TTL)
async def search_post(
request: Request,
Expand Down Expand Up @@ -903,10 +926,10 @@ async def search_post(
raise
except Exception:
logger.error("Search POST request failed", exc_info=True)
return JSONResponse(content={"error": "Search request failed"}, status_code=500)
return _search_error_response(request, code="search_request_failed")


@router.get("/search/facets/{facet_name}")
@router.get("/search/facets/{facet_name}", responses=COMMON_ERROR_RESPONSES)
@cached_endpoint(ttl=SEARCH_CACHE_TTL)
async def get_facet(
facet_name: str,
Expand Down Expand Up @@ -1043,10 +1066,15 @@ async def get_facet(
return JSONResponse(content={"error": "Invalid facet request"}, status_code=400)
except Exception:
logger.error("Error getting facet values", exc_info=True)
return JSONResponse(content={"error": "Failed to get facet values"}, status_code=500)
return _search_error_response(
request,
code="facet_values_failed",
title="Facet lookup failed",
detail="Failed to get facet values.",
)


@router.get("/suggest")
@router.get("/suggest", responses=COMMON_ERROR_RESPONSES)
@cached_endpoint(ttl=SUGGEST_CACHE_TTL)
async def suggest(
request: Request,
Expand All @@ -1069,4 +1097,9 @@ async def suggest(
raise
except Exception:
logger.error("Error getting suggestions", exc_info=True)
return JSONResponse(content={"error": "Failed to get suggestions"}, status_code=500)
return _search_error_response(
request,
code="suggestions_failed",
title="Suggestions failed",
detail="Failed to get suggestions.",
)
16 changes: 10 additions & 6 deletions backend/app/elasticsearch/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -1636,17 +1636,21 @@ async def finalize_response(
exc_info=True,
)

# If we get here, propagate a detailed HTTP error
# If we get here, propagate a public-safe HTTP error. The full query,
# index name, and upstream exception remain in logs via exc_info above.
error_detail = {
"message": "Elasticsearch query failed",
"error": str(es_error),
"query": search_query,
"index": index_name,
"code": "elasticsearch_query_failed",
}
if hasattr(es_error, "info"):
error_detail["info"] = es_error.info
info = getattr(es_error, "info", {}) or {}
upstream_status = info.get("status") if isinstance(info, dict) else None
if isinstance(upstream_status, int):
error_detail["upstream_status_code"] = upstream_status
if hasattr(es_error, "status_code"):
error_detail["status_code"] = es_error.status_code
status_code = es_error.status_code
if isinstance(status_code, int):
error_detail["upstream_status_code"] = status_code
raise HTTPException(status_code=500, detail=error_detail) from es_error

return await finalize_response(
Expand Down
29 changes: 20 additions & 9 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
FastAPIInstrumentor = None # Optional: requires appsignal/otel stack
from starlette.middleware.base import BaseHTTPMiddleware

from app.api.errors import (
RequestIDMiddleware,
get_request_id,
internal_server_error_response,
)
from app.api.ogc import router as ogc_router
from app.api.v1.endpoints import router as public_router
from app.elasticsearch import close_elasticsearch, init_elasticsearch
Expand Down Expand Up @@ -241,6 +246,9 @@ async def dispatch(self, request: Request, call_next):
# Add Cloudflare Turnstile browser gate middleware
app.add_middleware(TurnstileMiddleware)

# Add request IDs after other middleware so it runs first in the Starlette stack.
app.add_middleware(RequestIDMiddleware)

# Include routers
app.include_router(public_router, prefix="/api/v1")
app.include_router(ogc_router, prefix="/api/v1/ogc")
Expand Down Expand Up @@ -292,21 +300,24 @@ async def robots_txt() -> PlainTextResponse:
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""Global exception handler for the application."""
logger.error(f"Global exception handler caught: {str(exc)}", exc_info=True)
request_id = get_request_id(request)
logger.error(
"Global exception handler caught request_id=%s path=%s",
request_id,
request.url.path,
exc_info=True,
)

if isinstance(exc, HTTPException):
return JSONResponse(
response = JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
)
if request_id:
response.headers["X-Request-ID"] = request_id
return response

return JSONResponse(
status_code=500,
content={
"message": "An unexpected error occurred",
"error": str(exc),
},
)
return internal_server_error_response(request)


# Frontend is now served by React Router v7 in a separate Docker container
Expand Down
4 changes: 2 additions & 2 deletions backend/db/migrations/create_distribution_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ def create_distribution_tables():
(23, 'tile_json', 'TileJSON', 'https://github.com/mapbox/tilejson-spec', False, '-', 23),
(24, 'wcs', 'Web Coverage Service (WCS)', 'http://www.opengis.net/def/serviceType/ogc/wcs', False, '-', 24),
(25, 'wfs', 'Web Feature Service (WFS)', 'http://www.opengis.net/def/serviceType/ogc/wfs', False, 'Provides a to download generated vector datasets (GeoJSON, shapefile)', 25),
(26, 'wmts', 'Web Mapping Service (WMS)', 'http://www.opengis.net/def/serviceType/ogc/wms', False, 'Provides a service to visually preview a layer and inspect its features', 26),
(27, 'wms', 'WMTS', 'http://www.opengis.net/def/serviceType/ogc/wmts', False, '-', 27),
(26, 'wms', 'Web Mapping Service (WMS)', 'http://www.opengis.net/def/serviceType/ogc/wms', False, 'Provides a service to visually preview a layer and inspect its features', 26),
(27, 'wmts', 'WMTS', 'http://www.opengis.net/def/serviceType/ogc/wmts', False, '-', 27),
(28, 'xyz_tiles', 'XYZ tiles', 'https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames', False, 'Link to an XYZ tile server', 28)
]

Expand Down
4 changes: 2 additions & 2 deletions backend/db/migrations/create_distribution_types_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ def create_distribution_types_table():
(23, 'tile_json', 'TileJSON', 'https://github.com/mapbox/tilejson-spec', False, '-', 23),
(24, 'wcs', 'Web Coverage Service (WCS)', 'http://www.opengis.net/def/serviceType/ogc/wcs', False, '-', 24),
(25, 'wfs', 'Web Feature Service (WFS)', 'http://www.opengis.net/def/serviceType/ogc/wfs', False, 'Provides a to download generated vector datasets (GeoJSON, shapefile)', 25),
(26, 'wmts', 'Web Mapping Service (WMS)', 'http://www.opengis.net/def/serviceType/ogc/wms', False, 'Provides a service to visually preview a layer and inspect its features', 26),
(27, 'wms', 'WMTS', 'http://www.opengis.net/def/serviceType/ogc/wmts', False, '-', 27),
(26, 'wms', 'Web Mapping Service (WMS)', 'http://www.opengis.net/def/serviceType/ogc/wms', False, 'Provides a service to visually preview a layer and inspect its features', 26),
(27, 'wmts', 'WMTS', 'http://www.opengis.net/def/serviceType/ogc/wmts', False, '-', 27),
(28, 'xyz_tiles', 'XYZ tiles', 'https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames', False, 'Link to an XYZ tile server', 28)
]

Expand Down
Loading
Loading