Skip to content

Commit 6dc95a1

Browse files
authored
[ENH] RFC9457 compliant errors (#238)
Support [RFC9457](https://datatracker.ietf.org/doc/html/rfc9457) compliant errors. There are some improvements left out of scope: more consistent terminology in error details, changing some errors to normal responses (e.g., empty list instead of error).
1 parent 41f1973 commit 6dc95a1

21 files changed

Lines changed: 626 additions & 210 deletions

src/core/errors.py

Lines changed: 352 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,354 @@
1-
from enum import IntEnum
1+
"""RFC 9457 Problem Details for HTTP APIs.
22
3+
This module provides RFC 9457 compliant error handling for the OpenML REST API.
4+
See: https://www.rfc-editor.org/rfc/rfc9457.html
5+
"""
36

4-
class DatasetError(IntEnum):
5-
NOT_FOUND = 111
6-
NO_ACCESS = 112
7-
NO_DATA_FILE = 113
7+
from http import HTTPStatus
8+
9+
from fastapi import Request
10+
from fastapi.responses import JSONResponse
11+
12+
# =============================================================================
13+
# Base Exception
14+
# =============================================================================
15+
16+
17+
class ProblemDetailError(Exception):
18+
"""Base exception for RFC 9457 compliant error responses.
19+
20+
Subclasses should define class attributes:
21+
- uri: The problem type URI
22+
- title: Human-readable title
23+
- _default_status_code: HTTP status code
24+
- _default_code: Legacy error code (optional)
25+
26+
The status_code and code can be overridden per-instance.
27+
"""
28+
29+
uri: str = "about:blank"
30+
title: str = "An error occurred"
31+
_default_status_code: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR
32+
_default_code: int | None = None
33+
34+
def __init__(
35+
self,
36+
detail: str,
37+
*,
38+
code: int | str | None = None,
39+
instance: str | None = None,
40+
status_code: HTTPStatus | None = None,
41+
) -> None:
42+
self.detail = detail
43+
self._code_override = code
44+
self.instance = instance
45+
self._status_code_override = status_code
46+
super().__init__(detail)
47+
48+
@property
49+
def status_code(self) -> HTTPStatus:
50+
"""Return the status code, preferring instance override over class default."""
51+
if self._status_code_override is not None:
52+
return self._status_code_override
53+
return self._default_status_code
54+
55+
@property
56+
def code(self) -> int | str | None:
57+
"""Return the code, preferring instance override over class default."""
58+
if self._code_override is not None:
59+
return self._code_override
60+
return self._default_code
61+
62+
63+
def problem_detail_exception_handler(
64+
request: Request, # noqa: ARG001
65+
exc: ProblemDetailError,
66+
) -> JSONResponse:
67+
"""FastAPI exception handler for ProblemDetailError.
68+
69+
Returns a response with:
70+
- Content-Type: application/problem+json
71+
- RFC 9457 compliant JSON body
72+
"""
73+
content: dict[str, str | int] = {
74+
"type": exc.uri,
75+
"title": exc.title,
76+
"status": int(exc.status_code),
77+
"detail": exc.detail,
78+
}
79+
if exc.code is not None:
80+
content["code"] = str(exc.code)
81+
if exc.instance is not None:
82+
content["instance"] = exc.instance
83+
84+
return JSONResponse(
85+
status_code=int(exc.status_code),
86+
content=content,
87+
media_type="application/problem+json",
88+
)
89+
90+
91+
# =============================================================================
92+
# Dataset Errors
93+
# =============================================================================
94+
95+
96+
class DatasetNotFoundError(ProblemDetailError):
97+
"""Raised when a dataset cannot be found."""
98+
99+
uri = "https://openml.org/problems/dataset-not-found"
100+
title = "Dataset Not Found"
101+
_default_status_code = HTTPStatus.NOT_FOUND
102+
_default_code = 111
103+
104+
105+
class DatasetNoAccessError(ProblemDetailError):
106+
"""Raised when user doesn't have access to a dataset."""
107+
108+
uri = "https://openml.org/problems/dataset-no-access"
109+
title = "Dataset Access Denied"
110+
_default_status_code = HTTPStatus.FORBIDDEN
111+
_default_code = 112
112+
113+
114+
class DatasetNoDataFileError(ProblemDetailError):
115+
"""Raised when a dataset's data file is missing."""
116+
117+
uri = "https://openml.org/problems/dataset-no-data-file"
118+
title = "Dataset Data File Missing"
119+
_default_status_code = HTTPStatus.PRECONDITION_FAILED
120+
_default_code = 113
121+
122+
123+
class DatasetNotProcessedError(ProblemDetailError):
124+
"""Raised when a dataset has not been processed yet."""
125+
126+
uri = "https://openml.org/problems/dataset-not-processed"
127+
title = "Dataset Not Processed"
128+
_default_status_code = HTTPStatus.PRECONDITION_FAILED
129+
_default_code = 273
130+
131+
132+
class DatasetProcessingError(ProblemDetailError):
133+
"""Raised when a dataset had an error during processing."""
134+
135+
uri = "https://openml.org/problems/dataset-processing-error"
136+
title = "Dataset Processing Error"
137+
_default_status_code = HTTPStatus.PRECONDITION_FAILED
138+
_default_code = 274
139+
140+
141+
class DatasetNoFeaturesError(ProblemDetailError):
142+
"""Raised when a dataset has no features available."""
143+
144+
uri = "https://openml.org/problems/dataset-no-features"
145+
title = "Dataset Features Not Available"
146+
_default_status_code = HTTPStatus.PRECONDITION_FAILED
147+
_default_code = 272
148+
149+
150+
class DatasetStatusTransitionError(ProblemDetailError):
151+
"""Raised when an invalid dataset status transition is attempted."""
152+
153+
uri = "https://openml.org/problems/dataset-status-transition"
154+
title = "Invalid Status Transition"
155+
_default_status_code = HTTPStatus.PRECONDITION_FAILED
156+
_default_code = 694
157+
158+
159+
class DatasetNotOwnedError(ProblemDetailError):
160+
"""Raised when user tries to modify a dataset they don't own."""
161+
162+
uri = "https://openml.org/problems/dataset-not-owned"
163+
title = "Dataset Not Owned"
164+
_default_status_code = HTTPStatus.FORBIDDEN
165+
_default_code = 693
166+
167+
168+
class DatasetAdminOnlyError(ProblemDetailError):
169+
"""Raised when a non-admin tries to perform an admin-only action."""
170+
171+
uri = "https://openml.org/problems/dataset-admin-only"
172+
title = "Administrator Only"
173+
_default_status_code = HTTPStatus.FORBIDDEN
174+
_default_code = 696
175+
176+
177+
# =============================================================================
178+
# Authentication/Authorization Errors
179+
# =============================================================================
180+
181+
182+
class AuthenticationRequiredError(ProblemDetailError):
183+
"""Raised when authentication is required but not provided."""
184+
185+
uri = "https://openml.org/problems/authentication-required"
186+
title = "Authentication Required"
187+
_default_status_code = HTTPStatus.UNAUTHORIZED
188+
189+
190+
class AuthenticationFailedError(ProblemDetailError):
191+
"""Raised when authentication credentials are invalid."""
192+
193+
uri = "https://openml.org/problems/authentication-failed"
194+
title = "Authentication Failed"
195+
_default_status_code = HTTPStatus.UNAUTHORIZED
196+
_default_code = 103
197+
198+
199+
class ForbiddenError(ProblemDetailError):
200+
"""Raised when user is authenticated but not authorized."""
201+
202+
uri = "https://openml.org/problems/forbidden"
203+
title = "Forbidden"
204+
_default_status_code = HTTPStatus.FORBIDDEN
205+
206+
207+
# =============================================================================
208+
# Tag Errors
209+
# =============================================================================
210+
211+
212+
class TagAlreadyExistsError(ProblemDetailError):
213+
"""Raised when trying to add a tag that already exists."""
214+
215+
uri = "https://openml.org/problems/tag-already-exists"
216+
title = "Tag Already Exists"
217+
_default_status_code = HTTPStatus.CONFLICT
218+
_default_code = 473
219+
220+
221+
# =============================================================================
222+
# Search/List Errors
223+
# =============================================================================
224+
225+
226+
class NoResultsError(ProblemDetailError):
227+
"""Raised when a search returns no results."""
228+
229+
uri = "https://openml.org/problems/no-results"
230+
title = "No Results Found"
231+
_default_status_code = HTTPStatus.NOT_FOUND
232+
_default_code = 372
233+
234+
235+
# =============================================================================
236+
# Study Errors
237+
# =============================================================================
238+
239+
240+
class StudyNotFoundError(ProblemDetailError):
241+
"""Raised when a study cannot be found."""
242+
243+
uri = "https://openml.org/problems/study-not-found"
244+
title = "Study Not Found"
245+
_default_status_code = HTTPStatus.NOT_FOUND
246+
247+
248+
class StudyPrivateError(ProblemDetailError):
249+
"""Raised when trying to access a private study without permission."""
250+
251+
uri = "https://openml.org/problems/study-private"
252+
title = "Study Is Private"
253+
_default_status_code = HTTPStatus.FORBIDDEN
254+
255+
256+
class StudyLegacyError(ProblemDetailError):
257+
"""Raised when trying to access a legacy study that's no longer supported."""
258+
259+
uri = "https://openml.org/problems/study-legacy"
260+
title = "Legacy Study Not Supported"
261+
_default_status_code = HTTPStatus.GONE
262+
263+
264+
class StudyAliasExistsError(ProblemDetailError):
265+
"""Raised when trying to create a study with an alias that already exists."""
266+
267+
uri = "https://openml.org/problems/study-alias-exists"
268+
title = "Study Alias Already Exists"
269+
_default_status_code = HTTPStatus.CONFLICT
270+
271+
272+
class StudyInvalidTypeError(ProblemDetailError):
273+
"""Raised when study type configuration is invalid."""
274+
275+
uri = "https://openml.org/problems/study-invalid-type"
276+
title = "Invalid Study Type"
277+
_default_status_code = HTTPStatus.BAD_REQUEST
278+
279+
280+
class StudyNotEditableError(ProblemDetailError):
281+
"""Raised when trying to edit a study that cannot be edited."""
282+
283+
uri = "https://openml.org/problems/study-not-editable"
284+
title = "Study Not Editable"
285+
_default_status_code = HTTPStatus.FORBIDDEN
286+
287+
288+
class StudyConflictError(ProblemDetailError):
289+
"""Raised when there's a conflict with study data (e.g., duplicate attachment)."""
290+
291+
uri = "https://openml.org/problems/study-conflict"
292+
title = "Study Conflict"
293+
_default_status_code = HTTPStatus.CONFLICT
294+
295+
296+
# =============================================================================
297+
# Task Errors
298+
# =============================================================================
299+
300+
301+
class TaskNotFoundError(ProblemDetailError):
302+
"""Raised when a task cannot be found."""
303+
304+
uri = "https://openml.org/problems/task-not-found"
305+
title = "Task Not Found"
306+
_default_status_code = HTTPStatus.NOT_FOUND
307+
308+
309+
class TaskTypeNotFoundError(ProblemDetailError):
310+
"""Raised when a task type cannot be found."""
311+
312+
uri = "https://openml.org/problems/task-type-not-found"
313+
title = "Task Type Not Found"
314+
_default_status_code = HTTPStatus.NOT_FOUND
315+
_default_code = 241
316+
317+
318+
# =============================================================================
319+
# Flow Errors
320+
# =============================================================================
321+
322+
323+
class FlowNotFoundError(ProblemDetailError):
324+
"""Raised when a flow cannot be found."""
325+
326+
uri = "https://openml.org/problems/flow-not-found"
327+
title = "Flow Not Found"
328+
_default_status_code = HTTPStatus.NOT_FOUND
329+
330+
331+
# =============================================================================
332+
# Service Errors
333+
# =============================================================================
334+
335+
336+
class ServiceNotFoundError(ProblemDetailError):
337+
"""Raised when a service cannot be found."""
338+
339+
uri = "https://openml.org/problems/service-not-found"
340+
title = "Service Not Found"
341+
_default_status_code = HTTPStatus.NOT_FOUND
342+
343+
344+
# =============================================================================
345+
# Internal Errors
346+
# =============================================================================
347+
348+
349+
class InternalError(ProblemDetailError):
350+
"""Raised for unexpected internal server errors."""
351+
352+
uri = "https://openml.org/problems/internal-error"
353+
title = "Internal Server Error"
354+
_default_status_code = HTTPStatus.INTERNAL_SERVER_ERROR

src/core/formatting.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from sqlalchemy.engine import Row
44

55
from config import load_routing_configuration
6-
from core.errors import DatasetError
76
from schemas.datasets.openml import DatasetFileFormat
87

98

@@ -16,11 +15,6 @@ def _str_to_bool(string: str) -> bool:
1615
raise ValueError(msg)
1716

1817

19-
def _format_error(*, code: DatasetError, message: str) -> dict[str, str]:
20-
"""Formatter for JSON bodies of OpenML error codes."""
21-
return {"code": str(code), "message": message}
22-
23-
2418
def _format_parquet_url(dataset: Row) -> str | None:
2519
if dataset.format.lower() != DatasetFileFormat.ARFF:
2620
return None

src/database/users.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@
77

88
from config import load_configuration
99

10-
# Enforces str is 32 hexadecimal characters, does not check validity.
1110
# If `allow_test_api_keys` is set, the key may also be one of `normaluser`,
1211
# `normaluser2`, or `abc` (admin).
1312
api_key_pattern = r"^[0-9a-fA-F]{32}$"
14-
if load_configuration()["development"].get("allow_test_api_keys"):
13+
if load_configuration().get("development", {}).get("allow_test_api_keys"):
1514
api_key_pattern = r"^([0-9a-fA-F]{32}|normaluser|normaluser2|abc)$"
1615

1716
APIKey = Annotated[

0 commit comments

Comments
 (0)