Skip to content

Commit 77c4489

Browse files
Structured errors 2: more error types (#24)
* Structured errors 2: more error types * PR fixes * fix tests --------- Co-authored-by: Tim Pellissier <tpellissier@microsoft.com>
1 parent deccfa0 commit 77c4489

6 files changed

Lines changed: 248 additions & 108 deletions

File tree

src/dataverse_sdk/error_codes.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,46 @@
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"
48+
49+
# Mapping from status code -> subcode
50+
HTTP_STATUS_TO_SUBCODE: dict[int, str] = {
51+
400: HTTP_400,
52+
401: HTTP_401,
53+
403: HTTP_403,
54+
404: HTTP_404,
55+
409: HTTP_409,
56+
412: HTTP_412,
57+
415: HTTP_415,
58+
429: HTTP_429,
59+
500: HTTP_500,
60+
502: HTTP_502,
61+
503: HTTP_503,
62+
504: HTTP_504,
63+
}
64+
65+
TRANSIENT_STATUS = {429, 502, 503, 504}
66+
67+
def http_subcode(status: int) -> str:
68+
return HTTP_STATUS_TO_SUBCODE.get(status, f"http_{status}")
69+
70+
def is_transient_status(status: int) -> bool:
71+
return status in TRANSIENT_STATUS

src/dataverse_sdk/errors.py

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,16 @@ def __init__(
1212
subcode: Optional[str] = None,
1313
status_code: Optional[int] = None,
1414
details: Optional[Dict[str, Any]] = None,
15-
source: Optional[Dict[str, Any]] = None,
16-
is_transient: Optional[bool] = None,
15+
source: Optional[str] = None,
16+
is_transient: bool = False,
1717
) -> None:
1818
super().__init__(message)
1919
self.message = message
2020
self.code = code
2121
self.subcode = subcode
2222
self.status_code = status_code
2323
self.details = details or {}
24-
self.source = source or {}
24+
self.source = source or "client"
2525
self.is_transient = is_transient
2626
self.timestamp = _dt.datetime.utcnow().isoformat() + "Z"
2727

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

43+
class ValidationError(DataverseError):
44+
def __init__(self, message: str, *, subcode: Optional[str] = None, details: Optional[Dict[str, Any]] = None):
45+
super().__init__(message, code="validation_error", subcode=subcode, details=details, source="client")
46+
47+
class MetadataError(DataverseError):
48+
def __init__(self, message: str, *, subcode: Optional[str] = None, details: Optional[Dict[str, Any]] = None):
49+
super().__init__(message, code="metadata_error", subcode=subcode, details=details, source="client")
50+
51+
class SQLParseError(DataverseError):
52+
def __init__(self, message: str, *, subcode: Optional[str] = None, details: Optional[Dict[str, Any]] = None):
53+
super().__init__(message, code="sql_parse_error", subcode=subcode, details=details, source="client")
54+
4355
class HttpError(DataverseError):
4456
def __init__(
4557
self,
4658
message: str,
47-
*,
59+
status_code: int,
60+
is_transient: bool = False,
4861
subcode: Optional[str] = None,
49-
status_code: Optional[int] = None,
50-
details: Optional[Dict[str, Any]] = None,
51-
source: Optional[Dict[str, Any]] = None,
52-
is_transient: Optional[bool] = None,
62+
service_error_code: Optional[str] = None,
63+
correlation_id: Optional[str] = None,
64+
request_id: Optional[str] = None,
65+
traceparent: Optional[str] = None,
66+
body_excerpt: Optional[str] = None,
67+
retry_after: Optional[int] = None,
68+
details: Optional[Dict[str, Any]] = None
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: 63 additions & 38 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 *
1414
from . import error_codes as ec
1515

1616

@@ -76,45 +76,53 @@ 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"),
107-
}
102+
pass
103+
sc = r.status_code
104+
subcode = ec.http_subcode(sc)
105+
correlation_id = headers.get("x-ms-correlation-request-id") or headers.get("x-ms-correlation-id")
106+
request_id = headers.get("x-ms-client-request-id") or headers.get("request-id") or headers.get("x-ms-request-id")
107+
traceparent = headers.get("traceparent")
108108
ra = headers.get("Retry-After")
109+
retry_after = None
109110
if ra:
110-
details["retry_after"] = ra
111-
is_transient = r.status_code in (429, 502, 503, 504)
111+
try:
112+
retry_after = int(ra)
113+
except Exception:
114+
retry_after = None
115+
is_transient = ec.is_transient_status(sc)
112116
raise HttpError(
113-
message,
117+
msg,
118+
status_code=sc,
114119
subcode=subcode,
115-
status_code=r.status_code,
116-
details=details,
117-
source={"method": method, "url": url},
120+
service_error_code=svc_code,
121+
correlation_id=correlation_id,
122+
request_id=request_id,
123+
traceparent=traceparent,
124+
body_excerpt=body_excerpt,
125+
retry_after=retry_after,
118126
is_transient=is_transient,
119127
)
120128

@@ -500,8 +508,10 @@ def _query_sql(self, sql: str) -> list[dict[str, Any]]:
500508
RuntimeError
501509
If metadata lookup for the logical name fails.
502510
"""
503-
if not isinstance(sql, str) or not sql.strip():
504-
raise ValueError("sql must be a non-empty string")
511+
if not isinstance(sql, str):
512+
raise ValidationError("sql must be a string", subcode=ec.VALIDATION_SQL_NOT_STRING)
513+
if not sql.strip():
514+
raise ValidationError("sql must be a non-empty string", subcode=ec.VALIDATION_SQL_EMPTY)
505515
sql = sql.strip()
506516

507517
# Extract logical table name via helper (robust to identifiers ending with 'from')
@@ -570,11 +580,17 @@ def _entity_set_from_logical(self, logical: str) -> str:
570580
items = []
571581
if not items:
572582
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 ""
573-
raise RuntimeError(f"Unable to resolve entity set for logical name '{logical}'. Provide the singular logical name.{plural_hint}")
583+
raise MetadataError(
584+
f"Unable to resolve entity set for logical name '{logical}'. Provide the singular logical name.{plural_hint}",
585+
subcode=ec.METADATA_ENTITYSET_NOT_FOUND,
586+
)
574587
md = items[0]
575588
es = md.get("EntitySetName")
576589
if not es:
577-
raise RuntimeError(f"Metadata response missing EntitySetName for logical '{logical}'.")
590+
raise MetadataError(
591+
f"Metadata response missing EntitySetName for logical '{logical}'.",
592+
subcode=ec.METADATA_ENTITYSET_NAME_MISSING,
593+
)
578594
self._logical_to_entityset_cache[logical] = es
579595
primary_id_attr = md.get("PrimaryIdAttribute")
580596
if isinstance(primary_id_attr, str) and primary_id_attr:
@@ -1014,7 +1030,10 @@ def _delete_table(self, tablename: str) -> None:
10141030
entity_schema = schema_name
10151031
ent = self._get_entity_by_schema(entity_schema)
10161032
if not ent or not ent.get("MetadataId"):
1017-
raise RuntimeError(f"Table '{entity_schema}' not found.")
1033+
raise MetadataError(
1034+
f"Table '{entity_schema}' not found.",
1035+
subcode=ec.METADATA_TABLE_NOT_FOUND,
1036+
)
10181037
metadata_id = ent["MetadataId"]
10191038
url = f"{self.api}/EntityDefinitions({metadata_id})"
10201039
r = self._request("delete", url)
@@ -1026,7 +1045,10 @@ def _create_table(self, tablename: str, schema: Dict[str, Any]) -> Dict[str, Any
10261045

10271046
ent = self._get_entity_by_schema(entity_schema)
10281047
if ent:
1029-
raise RuntimeError(f"Table '{entity_schema}' already exists. No update performed.")
1048+
raise MetadataError(
1049+
f"Table '{entity_schema}' already exists.",
1050+
subcode=ec.METADATA_TABLE_ALREADY_EXISTS,
1051+
)
10301052

10311053
created_cols: List[str] = []
10321054
primary_attr_schema = "new_Name" if "_" not in entity_schema else f"{entity_schema.split('_',1)[0]}_Name"
@@ -1080,7 +1102,10 @@ def _flush_cache(
10801102
"""
10811103
k = (kind or "").strip().lower()
10821104
if k != "picklist":
1083-
raise ValueError(f"Unsupported cache kind '{kind}' (only 'picklist' is implemented)")
1105+
raise ValidationError(
1106+
f"Unsupported cache kind '{kind}' (only 'picklist' is implemented)",
1107+
subcode=ec.VALIDATION_UNSUPPORTED_CACHE_KIND,
1108+
)
10841109

10851110
removed = len(self._picklist_label_cache)
10861111
self._picklist_label_cache.clear()

tests/test_create_single_guid.py

Lines changed: 0 additions & 54 deletions
This file was deleted.

0 commit comments

Comments
 (0)