From 67f31187a07b394beb10b6273f80b55a601ada0e Mon Sep 17 00:00:00 2001
From: PGijsbers
Date: Mon, 13 Apr 2026 15:20:43 +0200
Subject: [PATCH 1/5] Wrap input validation error in RFC9457 response
---
src/core/errors.py | 22 ++++++++++++++++++++++
src/main.py | 9 ++++++++-
tests/routers/openml/dataset_tag_test.py | 7 ++++++-
tests/routers/openml/task_list_test.py | 14 +++++++-------
4 files changed, 43 insertions(+), 9 deletions(-)
diff --git a/src/core/errors.py b/src/core/errors.py
index dea1f50..055dc9b 100644
--- a/src/core/errors.py
+++ b/src/core/errors.py
@@ -7,6 +7,7 @@
from http import HTTPStatus
from fastapi import Request
+from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
# =============================================================================
@@ -89,6 +90,27 @@ def problem_detail_exception_handler(
)
+def validation_exception_handler(
+ request: Request, # noqa: ARG001
+ exc: RequestValidationError,
+) -> JSONResponse:
+ """FastAPI exception handler for RequestValidationError.
+
+ Returns a RFC 9457 compliant response for input validation failures.
+ """
+ return JSONResponse(
+ status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
+ content={
+ "type": "about:blank",
+ "title": "Validation Error",
+ "status": HTTPStatus.UNPROCESSABLE_ENTITY,
+ "detail": "Input validation failed.",
+ "errors": exc.errors(),
+ },
+ media_type="application/problem+json",
+ )
+
+
# =============================================================================
# Dataset Errors
# =============================================================================
diff --git a/src/main.py b/src/main.py
index 3be2c5c..3af0e23 100644
--- a/src/main.py
+++ b/src/main.py
@@ -10,7 +10,13 @@
from loguru import logger
from config import load_configuration
-from core.errors import ProblemDetailError, problem_detail_exception_handler
+from fastapi.exceptions import RequestValidationError
+
+from core.errors import (
+ ProblemDetailError,
+ problem_detail_exception_handler,
+ validation_exception_handler,
+)
from core.logging import (
add_request_context_to_log,
log_request_duration,
@@ -87,6 +93,7 @@ def create_api(configuration_file: Path | None = None) -> FastAPI:
app.middleware("http")(add_request_context_to_log)
app.add_exception_handler(ProblemDetailError, problem_detail_exception_handler) # type: ignore[arg-type]
+ app.add_exception_handler(RequestValidationError, validation_exception_handler) # type: ignore[arg-type]
logger.info("Adding routers to app")
app.include_router(datasets_router)
diff --git a/tests/routers/openml/dataset_tag_test.py b/tests/routers/openml/dataset_tag_test.py
index cddd0d8..fbe1ffe 100644
--- a/tests/routers/openml/dataset_tag_test.py
+++ b/tests/routers/openml/dataset_tag_test.py
@@ -42,7 +42,12 @@ async def test_dataset_tag_invalid_tag_is_rejected(
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
- assert response.json()["detail"][0]["loc"] == ["body", "tag"]
+ assert response.headers["content-type"] == "application/problem+json"
+ body = response.json()
+ assert body["type"] == "about:blank"
+ assert body["title"] == "Validation Error"
+ assert body["status"] == HTTPStatus.UNPROCESSABLE_ENTITY
+ assert body["errors"][0]["loc"] == ["body", "tag"]
# ── Direct call tests: tag_dataset ──
diff --git a/tests/routers/openml/task_list_test.py b/tests/routers/openml/task_list_test.py
index 4667967..67d8539 100644
--- a/tests/routers/openml/task_list_test.py
+++ b/tests/routers/openml/task_list_test.py
@@ -135,9 +135,9 @@ async def test_list_tasks_invalid_pagination_type(
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
# Verify that the error points to the correct field
- detail = response.json()["detail"][0]
- assert detail["loc"][-2:] == ["pagination", expected_field]
- assert detail["type"] in {"type_error.integer", "int_parsing", "int_type"}
+ error = response.json()["errors"][0]
+ assert error["loc"][-2:] == ["pagination", expected_field]
+ assert error["type"] in {"type_error.integer", "int_parsing", "int_type"}
@pytest.mark.parametrize(
@@ -150,8 +150,8 @@ async def test_list_tasks_invalid_range(value: str, py_api: httpx.AsyncClient) -
response = await py_api.post("/tasks/list", json={"number_instances": value})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
# Verify the error is for the correct field
- detail = response.json()["detail"][0]
- assert detail["loc"][-1] == "number_instances"
+ error = response.json()["errors"][0]
+ assert error["loc"][-1] == "number_instances"
@pytest.mark.parametrize(
@@ -171,9 +171,9 @@ async def test_list_tasks_invalid_inputs(
response = await py_api.post("/tasks/list", json=payload)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
# Ensure we are failing for the field we provided
- detail = response.json()["detail"][0]
+ error = response.json()["errors"][0]
expected_field = next(iter(payload))
- assert detail["loc"][-1] == expected_field
+ assert error["loc"][-1] == expected_field
async def test_list_tasks_no_results_api_mapping(py_api: httpx.AsyncClient) -> None:
From f42d6628b8f4cb3f4d88dc028689e7c4a25d484f Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
<66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Mon, 13 Apr 2026 13:22:22 +0000
Subject: [PATCH 2/5] [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---
src/main.py | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/main.py b/src/main.py
index 3af0e23..46cd79c 100644
--- a/src/main.py
+++ b/src/main.py
@@ -7,11 +7,10 @@
import uvicorn
from fastapi import FastAPI
+from fastapi.exceptions import RequestValidationError
from loguru import logger
from config import load_configuration
-from fastapi.exceptions import RequestValidationError
-
from core.errors import (
ProblemDetailError,
problem_detail_exception_handler,
From fca6c1dbf637364023f1aa23aa0fa0b05def361b Mon Sep 17 00:00:00 2001
From: PGijsbers
Date: Mon, 13 Apr 2026 15:27:12 +0200
Subject: [PATCH 3/5] Test only relevent field content is rejected
---
tests/routers/openml/dataset_tag_test.py | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/tests/routers/openml/dataset_tag_test.py b/tests/routers/openml/dataset_tag_test.py
index fbe1ffe..08d78de 100644
--- a/tests/routers/openml/dataset_tag_test.py
+++ b/tests/routers/openml/dataset_tag_test.py
@@ -42,12 +42,7 @@ async def test_dataset_tag_invalid_tag_is_rejected(
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
- assert response.headers["content-type"] == "application/problem+json"
- body = response.json()
- assert body["type"] == "about:blank"
- assert body["title"] == "Validation Error"
- assert body["status"] == HTTPStatus.UNPROCESSABLE_ENTITY
- assert body["errors"][0]["loc"] == ["body", "tag"]
+ assert response.json()[0]["loc"] == ["body", "tag"]
# ── Direct call tests: tag_dataset ──
From e5f09bfc612dbc08444e8b5c6aba998c64e4800e Mon Sep 17 00:00:00 2001
From: PGijsbers
Date: Mon, 13 Apr 2026 15:33:55 +0200
Subject: [PATCH 4/5] Add a type
---
src/core/errors.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/core/errors.py b/src/core/errors.py
index 055dc9b..69d7e0c 100644
--- a/src/core/errors.py
+++ b/src/core/errors.py
@@ -101,7 +101,7 @@ def validation_exception_handler(
return JSONResponse(
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
content={
- "type": "about:blank",
+ "type": "https://openml.org/problems/validation-error",
"title": "Validation Error",
"status": HTTPStatus.UNPROCESSABLE_ENTITY,
"detail": "Input validation failed.",
From 2814c6aa379ffe5fa640983c857e91ff638cfd79 Mon Sep 17 00:00:00 2001
From: PGijsbers
Date: Mon, 13 Apr 2026 15:35:44 +0200
Subject: [PATCH 5/5] Fetch error from response
---
tests/routers/openml/dataset_tag_test.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/routers/openml/dataset_tag_test.py b/tests/routers/openml/dataset_tag_test.py
index 08d78de..d11fc96 100644
--- a/tests/routers/openml/dataset_tag_test.py
+++ b/tests/routers/openml/dataset_tag_test.py
@@ -42,7 +42,7 @@ async def test_dataset_tag_invalid_tag_is_rejected(
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
- assert response.json()[0]["loc"] == ["body", "tag"]
+ assert response.json()["errors"][0]["loc"] == ["body", "tag"]
# ── Direct call tests: tag_dataset ──