Skip to content

Commit 9917272

Browse files
committed
Structured errors 2: more error types
1 parent fa6ce60 commit 9917272

5 files changed

Lines changed: 231 additions & 49 deletions

File tree

src/dataverse_sdk/error_codes.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,22 @@
2626
HTTP_503,
2727
HTTP_504,
2828
}
29+
30+
# Validation subcodes
31+
VALIDATION_SQL_NOT_STRING = "validation_sql_not_string"
32+
VALIDATION_SQL_EMPTY = "validation_sql_empty"
33+
VALIDATION_ENUM_NO_MEMBERS = "validation_enum_no_members"
34+
VALIDATION_ENUM_NON_INT_VALUE = "validation_enum_non_int_value"
35+
VALIDATION_UNSUPPORTED_COLUMN_TYPE = "validation_unsupported_column_type"
36+
VALIDATION_UNSUPPORTED_CACHE_KIND = "validation_unsupported_cache_kind"
37+
38+
# SQL parse subcodes
39+
SQL_PARSE_TABLE_NOT_FOUND = "sql_parse_table_not_found"
40+
41+
# Metadata subcodes
42+
METADATA_ENTITYSET_NOT_FOUND = "metadata_entityset_not_found"
43+
METADATA_ENTITYSET_NAME_MISSING = "metadata_entityset_name_missing"
44+
METADATA_TABLE_NOT_FOUND = "metadata_table_not_found"
45+
METADATA_TABLE_ALREADY_EXISTS = "metadata_table_already_exists"
46+
METADATA_ATTRIBUTE_RETRY_EXHAUSTED = "metadata_attribute_retry_exhausted"
47+
METADATA_PICKLIST_RETRY_EXHAUSTED = "metadata_picklist_retry_exhausted"

src/dataverse_sdk/errors.py

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import datetime as _dt
44

55
class DataverseError(Exception):
6-
"""Base structured error for the Dataverse SDK."""
76
def __init__(
87
self,
98
message: str,
@@ -12,16 +11,16 @@ def __init__(
1211
subcode: Optional[str] = None,
1312
status_code: Optional[int] = None,
1413
details: Optional[Dict[str, Any]] = None,
15-
source: Optional[Dict[str, Any]] = None,
16-
is_transient: Optional[bool] = None,
14+
source: Optional[str] = None,
15+
is_transient: bool = False,
1716
) -> None:
1817
super().__init__(message)
1918
self.message = message
2019
self.code = code
2120
self.subcode = subcode
2221
self.status_code = status_code
2322
self.details = details or {}
24-
self.source = source or {}
23+
self.source = source or "client"
2524
self.is_transient = is_transient
2625
self.timestamp = _dt.datetime.utcnow().isoformat() + "Z"
2726

@@ -40,25 +39,55 @@ def to_dict(self) -> Dict[str, Any]:
4039
def __repr__(self) -> str: # pragma: no cover
4140
return f"{self.__class__.__name__}(code={self.code!r}, subcode={self.subcode!r}, message={self.message!r})"
4241

42+
class ValidationError(DataverseError):
43+
def __init__(self, message: str, *, subcode: Optional[str] = None, details: Optional[Dict[str, Any]] = None):
44+
super().__init__(message, code="validation_error", subcode=subcode, details=details, source="client")
45+
46+
class MetadataError(DataverseError):
47+
def __init__(self, message: str, *, subcode: Optional[str] = None, details: Optional[Dict[str, Any]] = None):
48+
super().__init__(message, code="metadata_error", subcode=subcode, details=details, source="client")
49+
50+
class SQLParseError(DataverseError):
51+
def __init__(self, message: str, *, subcode: Optional[str] = None, details: Optional[Dict[str, Any]] = None):
52+
super().__init__(message, code="sql_parse_error", subcode=subcode, details=details, source="client")
53+
4354
class HttpError(DataverseError):
4455
def __init__(
4556
self,
4657
message: str,
4758
*,
59+
status_code: int,
4860
subcode: Optional[str] = None,
49-
status_code: Optional[int] = None,
61+
service_error_code: Optional[str] = None,
62+
correlation_id: Optional[str] = None,
63+
request_id: Optional[str] = None,
64+
traceparent: Optional[str] = None,
65+
body_excerpt: Optional[str] = None,
66+
retry_after: Optional[int] = None,
5067
details: Optional[Dict[str, Any]] = None,
51-
source: Optional[Dict[str, Any]] = None,
52-
is_transient: Optional[bool] = None,
68+
is_transient: bool = False,
5369
) -> None:
70+
d = details or {}
71+
if service_error_code is not None:
72+
d["service_error_code"] = service_error_code
73+
if correlation_id is not None:
74+
d["correlation_id"] = correlation_id
75+
if request_id is not None:
76+
d["request_id"] = request_id
77+
if traceparent is not None:
78+
d["traceparent"] = traceparent
79+
if body_excerpt is not None:
80+
d["body_excerpt"] = body_excerpt
81+
if retry_after is not None:
82+
d["retry_after"] = retry_after
5483
super().__init__(
5584
message,
56-
code="http",
85+
code="http_error",
5786
subcode=subcode,
5887
status_code=status_code,
59-
details=details,
60-
source=source,
88+
details=d,
89+
source="server",
6190
is_transient=is_transient,
6291
)
6392

64-
__all__ = ["DataverseError", "HttpError"]
93+
__all__ = ["DataverseError", "HttpError", "ValidationError", "MetadataError", "SQLParseError"]

src/dataverse_sdk/odata.py

Lines changed: 76 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from .http import HttpClient
1212
from .odata_upload_files import ODataFileUpload
13-
from .errors import HttpError
13+
from .errors import HttpError, ValidationError, MetadataError, SQLParseError
1414
from . import error_codes as ec
1515

1616

@@ -76,45 +76,67 @@ def _raw_request(self, method: str, url: str, **kwargs):
7676
return self._http.request(method, url, **kwargs)
7777

7878
def _request(self, method: str, url: str, *, expected: tuple[int, ...] = (200, 201, 202, 204), **kwargs):
79-
"""Execute HTTP request; raise HttpError with structured details on failure.
80-
81-
Returns the raw response for success codes; raises HttpError with extracted
82-
Dataverse error payload fields and correlation identifiers otherwise.
83-
"""
84-
headers = kwargs.pop("headers", None)
85-
kwargs["headers"] = self._merge_headers(headers)
79+
headers_in = kwargs.pop("headers", None)
80+
kwargs["headers"] = self._merge_headers(headers_in)
8681
r = self._raw_request(method, url, **kwargs)
8782
if r.status_code in expected:
8883
return r
89-
payload = {}
84+
headers = getattr(r, "headers", {}) or {}
85+
body_excerpt = (getattr(r, "text", "") or "")[:200]
86+
svc_code = None
87+
msg = f"HTTP {r.status_code}"
9088
try:
91-
payload = r.json() if getattr(r, 'text', None) else {}
89+
data = r.json() if getattr(r, "text", None) else {}
90+
if isinstance(data, dict):
91+
inner = data.get("error")
92+
if isinstance(inner, dict):
93+
svc_code = inner.get("code")
94+
imsg = inner.get("message")
95+
if isinstance(imsg, str) and imsg.strip():
96+
msg = imsg.strip()
97+
else:
98+
imsg2 = data.get("message")
99+
if isinstance(imsg2, str) and imsg2.strip():
100+
msg = imsg2.strip()
92101
except Exception:
93-
payload = {}
94-
svc_err = payload.get("error") if isinstance(payload, dict) else None
95-
svc_code = svc_err.get("code") if isinstance(svc_err, dict) else None
96-
svc_msg = svc_err.get("message") if isinstance(svc_err, dict) else None
97-
message = svc_msg or f"HTTP {r.status_code}"
98-
subcode = f"http_{r.status_code}"
99-
100-
headers = getattr(r, 'headers', {}) or {}
101-
details = {
102-
"service_error_code": svc_code,
103-
"body_excerpt": (getattr(r, 'text', '') or '')[:200],
104-
"correlation_id": headers.get("x-ms-correlation-request-id") or headers.get("x-ms-correlation-id"),
105-
"request_id": headers.get("x-ms-client-request-id") or headers.get("request-id"),
106-
"traceparent": headers.get("traceparent"),
102+
pass
103+
sc = r.status_code
104+
sub_map = {
105+
400: ec.HTTP_400,
106+
401: ec.HTTP_401,
107+
403: ec.HTTP_403,
108+
404: ec.HTTP_404,
109+
409: ec.HTTP_409,
110+
412: ec.HTTP_412,
111+
415: ec.HTTP_415,
112+
429: ec.HTTP_429,
113+
500: ec.HTTP_500,
114+
502: ec.HTTP_502,
115+
503: ec.HTTP_503,
116+
504: ec.HTTP_504,
107117
}
118+
subcode = sub_map.get(sc, f"http_{sc}")
119+
correlation_id = headers.get("x-ms-correlation-request-id") or headers.get("x-ms-correlation-id")
120+
request_id = headers.get("x-ms-client-request-id") or headers.get("request-id") or headers.get("x-ms-request-id")
121+
traceparent = headers.get("traceparent")
108122
ra = headers.get("Retry-After")
123+
retry_after = None
109124
if ra:
110-
details["retry_after"] = ra
111-
is_transient = r.status_code in (429, 502, 503, 504)
125+
try:
126+
retry_after = int(ra)
127+
except Exception:
128+
retry_after = None
129+
is_transient = sc in (429, 502, 503, 504)
112130
raise HttpError(
113-
message,
131+
msg,
132+
status_code=sc,
114133
subcode=subcode,
115-
status_code=r.status_code,
116-
details=details,
117-
source={"method": method, "url": url},
134+
service_error_code=svc_code,
135+
correlation_id=correlation_id,
136+
request_id=request_id,
137+
traceparent=traceparent,
138+
body_excerpt=body_excerpt,
139+
retry_after=retry_after,
118140
is_transient=is_transient,
119141
)
120142

@@ -497,8 +519,10 @@ def _query_sql(self, sql: str) -> list[dict[str, Any]]:
497519
RuntimeError
498520
If metadata lookup for the logical name fails.
499521
"""
500-
if not isinstance(sql, str) or not sql.strip():
501-
raise ValueError("sql must be a non-empty string")
522+
if not isinstance(sql, str):
523+
raise ValidationError("sql must be a string", subcode=ec.VALIDATION_SQL_NOT_STRING)
524+
if not sql.strip():
525+
raise ValidationError("sql must be a non-empty string", subcode=ec.VALIDATION_SQL_EMPTY)
502526
sql = sql.strip()
503527

504528
# Extract logical table name via helper (robust to identifiers ending with 'from')
@@ -567,11 +591,17 @@ def _entity_set_from_logical(self, logical: str) -> str:
567591
items = []
568592
if not items:
569593
plural_hint = " (did you pass a plural entity set name instead of the singular logical name?)" if logical.endswith("s") and not logical.endswith("ss") else ""
570-
raise RuntimeError(f"Unable to resolve entity set for logical name '{logical}'. Provide the singular logical name.{plural_hint}")
594+
raise MetadataError(
595+
f"Unable to resolve entity set for logical name '{logical}'. Provide the singular logical name.{plural_hint}",
596+
subcode=ec.METADATA_ENTITYSET_NOT_FOUND,
597+
)
571598
md = items[0]
572599
es = md.get("EntitySetName")
573600
if not es:
574-
raise RuntimeError(f"Metadata response missing EntitySetName for logical '{logical}'.")
601+
raise MetadataError(
602+
f"Metadata response missing EntitySetName for logical '{logical}'.",
603+
subcode=ec.METADATA_ENTITYSET_NAME_MISSING,
604+
)
575605
self._logical_to_entityset_cache[logical] = es
576606
primary_id_attr = md.get("PrimaryIdAttribute")
577607
if isinstance(primary_id_attr, str) and primary_id_attr:
@@ -1011,7 +1041,10 @@ def _delete_table(self, tablename: str) -> None:
10111041
entity_schema = schema_name
10121042
ent = self._get_entity_by_schema(entity_schema)
10131043
if not ent or not ent.get("MetadataId"):
1014-
raise RuntimeError(f"Table '{entity_schema}' not found.")
1044+
raise MetadataError(
1045+
f"Table '{entity_schema}' not found.",
1046+
subcode=ec.METADATA_TABLE_NOT_FOUND,
1047+
)
10151048
metadata_id = ent["MetadataId"]
10161049
url = f"{self.api}/EntityDefinitions({metadata_id})"
10171050
r = self._request("delete", url)
@@ -1023,7 +1056,10 @@ def _create_table(self, tablename: str, schema: Dict[str, Any]) -> Dict[str, Any
10231056

10241057
ent = self._get_entity_by_schema(entity_schema)
10251058
if ent:
1026-
raise RuntimeError(f"Table '{entity_schema}' already exists. No update performed.")
1059+
raise MetadataError(
1060+
f"Table '{entity_schema}' already exists.",
1061+
subcode=ec.METADATA_TABLE_ALREADY_EXISTS,
1062+
)
10271063

10281064
created_cols: List[str] = []
10291065
primary_attr_schema = "new_Name" if "_" not in entity_schema else f"{entity_schema.split('_',1)[0]}_Name"
@@ -1077,7 +1113,10 @@ def _flush_cache(
10771113
"""
10781114
k = (kind or "").strip().lower()
10791115
if k != "picklist":
1080-
raise ValueError(f"Unsupported cache kind '{kind}' (only 'picklist' is implemented)")
1116+
raise ValidationError(
1117+
f"Unsupported cache kind '{kind}' (only 'picklist' is implemented)",
1118+
subcode=ec.VALIDATION_UNSUPPORTED_CACHE_KIND,
1119+
)
10811120

10821121
removed = len(self._picklist_label_cache)
10831122
self._picklist_label_cache.clear()

tests/test_http_errors.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import pytest
2+
from dataverse_sdk.errors import HttpError
3+
from dataverse_sdk import error_codes as ec
4+
from dataverse_sdk.odata import ODataClient
5+
6+
class DummyAuth:
7+
def acquire_token(self, scope):
8+
class T: access_token = "x"
9+
return T()
10+
11+
class DummyHTTP:
12+
def __init__(self, responses):
13+
self._responses = responses
14+
def request(self, method, url, **kwargs):
15+
if not self._responses:
16+
raise AssertionError("No more responses")
17+
status, headers, body = self._responses.pop(0)
18+
class R:
19+
pass
20+
r = R()
21+
r.status_code = status
22+
r.headers = headers
23+
if isinstance(body, dict):
24+
import json
25+
r.text = json.dumps(body)
26+
def json_func(): return body
27+
r.json = json_func
28+
else:
29+
r.text = body or ""
30+
def json_fail(): raise ValueError("non-json")
31+
r.json = json_fail
32+
return r
33+
34+
class TestClient(ODataClient):
35+
def __init__(self, responses):
36+
super().__init__(DummyAuth(), "https://org.example", None)
37+
self._http = DummyHTTP(responses)
38+
39+
# --- Tests ---
40+
41+
def test_http_404_subcode_and_service_code():
42+
responses = [(
43+
404,
44+
{"x-ms-correlation-request-id": "cid1"},
45+
{"error": {"code": "0x800404", "message": "Not found"}},
46+
)]
47+
c = TestClient(responses)
48+
with pytest.raises(HttpError) as ei:
49+
c._request("get", c.api + "/accounts(abc)")
50+
err = ei.value.to_dict()
51+
assert err["subcode"] == ec.HTTP_404
52+
assert err["details"]["service_error_code"] == "0x800404"
53+
54+
55+
def test_http_429_transient_and_retry_after():
56+
responses = [(
57+
429,
58+
{"Retry-After": "7"},
59+
{"error": {"message": "Throttle"}},
60+
)]
61+
c = TestClient(responses)
62+
with pytest.raises(HttpError) as ei:
63+
c._request("get", c.api + "/accounts")
64+
err = ei.value.to_dict()
65+
assert err["is_transient"] is True
66+
assert err["subcode"] == ec.HTTP_429
67+
assert err["details"]["retry_after"] == 7
68+
69+
70+
def test_http_500_body_excerpt():
71+
responses = [(
72+
500,
73+
{},
74+
"Internal failure XYZ stack truncated",
75+
)]
76+
c = TestClient(responses)
77+
with pytest.raises(HttpError) as ei:
78+
c._request("get", c.api + "/accounts")
79+
err = ei.value.to_dict()
80+
assert err["subcode"] == ec.HTTP_500
81+
assert "XYZ stack" in err["details"]["body_excerpt"]
82+
83+
84+
def test_http_non_mapped_status_code_subcode_fallback():
85+
responses = [(
86+
418, # I'm a teapot (not in map)
87+
{},
88+
{"error": {"message": "Teapot"}},
89+
)]
90+
c = TestClient(responses)
91+
with pytest.raises(HttpError) as ei:
92+
c._request("get", c.api + "/accounts")
93+
err = ei.value.to_dict()
94+
assert err["subcode"] == "http_418"

tests/test_logical_crud.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import types
22
import pytest
33
from dataverse_sdk.odata import ODataClient
4+
from dataverse_sdk.errors import MetadataError
45

56
class DummyAuth:
67
def acquire_token(self, scope):
@@ -115,5 +116,5 @@ def test_unknown_logical_name_raises():
115116
(200, {}, {"value": []}), # metadata lookup returns empty
116117
]
117118
c = TestableClient(responses)
118-
with pytest.raises(RuntimeError):
119+
with pytest.raises(MetadataError):
119120
c._create("nonexistent", {"x": 1})

0 commit comments

Comments
 (0)