Skip to content

Commit a1d010b

Browse files
Structured errors part 1: common HTTP wrapper (#22)
* fix unit tests after optionset / logicalname changes * undo code from another branch * Add HTTP wrapper and extract DV response info * duplicate line * comment fixes * PR fixes --------- Co-authored-by: Tim Pellissier <tpellissier@microsoft.com>
1 parent 2218d5b commit a1d010b

5 files changed

Lines changed: 149 additions & 71 deletions

File tree

examples/quickstart_file_upload.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,6 @@ def ensure_file_attribute_generic(schema_name: str, label: str, key_prefix: str)
192192
f"SchemaName eq '{schema_name}'"
193193
)
194194
r = odata._request("get", url, headers=odata._headers())
195-
r.raise_for_status()
196195
val = []
197196
try:
198197
val = r.json().get("value", [])
@@ -217,7 +216,6 @@ def ensure_file_attribute_generic(schema_name: str, label: str, key_prefix: str)
217216
try:
218217
url = f"{odata.api}/EntityDefinitions({meta_id})/Attributes"
219218
r = odata._request("post", url, headers=odata._headers(), json=payload)
220-
r.raise_for_status()
221219
print({f"{key_prefix}_file_attribute_created": True})
222220
time.sleep(2)
223221
return True
@@ -291,7 +289,6 @@ def get_dataset_info(file_path: Path):
291289
odata = client._get_odata()
292290
dl_url_single = f"{odata.api}/{entity_set}({record_id})/{small_file_attr_logical}/$value" # raw entity_set URL OK
293291
resp_single = odata._request("get", dl_url_single, headers=odata._headers())
294-
resp_single.raise_for_status()
295292
content_single = resp_single.content or b""
296293
import hashlib # noqa: WPS433
297294
downloaded_hash = hashlib.sha256(content_single).hexdigest() if content_single else None
@@ -317,7 +314,6 @@ def get_dataset_info(file_path: Path):
317314
))
318315
print({"small_replace_upload_completed": True, "small_replace_source_size": replace_size_small})
319316
resp_single_replace = odata._request("get", dl_url_single, headers=odata._headers())
320-
resp_single_replace.raise_for_status()
321317
content_single_replace = resp_single_replace.content or b""
322318
downloaded_hash_replace = hashlib.sha256(content_single_replace).hexdigest() if content_single_replace else None
323319
hash_match_replace = (downloaded_hash_replace == replace_hash_small) if (downloaded_hash_replace and replace_hash_small) else None
@@ -348,7 +344,6 @@ def get_dataset_info(file_path: Path):
348344
odata = client._get_odata()
349345
dl_url_chunk = f"{odata.api}/{entity_set}({record_id})/{chunk_file_attr_logical}/$value" # raw entity_set for download
350346
resp_chunk = odata._request("get", dl_url_chunk, headers=odata._headers())
351-
resp_chunk.raise_for_status()
352347
content_chunk = resp_chunk.content or b""
353348
import hashlib # noqa: WPS433
354349
dst_hash_chunk = hashlib.sha256(content_chunk).hexdigest() if content_chunk else None
@@ -373,7 +368,6 @@ def get_dataset_info(file_path: Path):
373368
))
374369
print({"chunk_replace_upload_completed": True})
375370
resp_chunk_replace = odata._request("get", dl_url_chunk, headers=odata._headers())
376-
resp_chunk_replace.raise_for_status()
377371
content_chunk_replace = resp_chunk_replace.content or b""
378372
dst_hash_chunk_replace = hashlib.sha256(content_chunk_replace).hexdigest() if content_chunk_replace else None
379373
hash_match_chunk_replace = (dst_hash_chunk_replace == replace_hash_chunk) if (dst_hash_chunk_replace and replace_hash_chunk) else None

src/dataverse_sdk/error_codes.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# HTTP subcode constants
2+
HTTP_400 = "http_400"
3+
HTTP_401 = "http_401"
4+
HTTP_403 = "http_403"
5+
HTTP_404 = "http_404"
6+
HTTP_409 = "http_409"
7+
HTTP_412 = "http_412"
8+
HTTP_415 = "http_415"
9+
HTTP_429 = "http_429"
10+
HTTP_500 = "http_500"
11+
HTTP_502 = "http_502"
12+
HTTP_503 = "http_503"
13+
HTTP_504 = "http_504"
14+
15+
ALL_HTTP_SUBCODES = {
16+
HTTP_400,
17+
HTTP_401,
18+
HTTP_403,
19+
HTTP_404,
20+
HTTP_409,
21+
HTTP_412,
22+
HTTP_415,
23+
HTTP_429,
24+
HTTP_500,
25+
HTTP_502,
26+
HTTP_503,
27+
HTTP_504,
28+
}

src/dataverse_sdk/errors.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
from typing import Any, Dict, Optional
3+
import datetime as _dt
4+
5+
class DataverseError(Exception):
6+
"""Base structured error for the Dataverse SDK."""
7+
def __init__(
8+
self,
9+
message: str,
10+
*,
11+
code: str,
12+
subcode: Optional[str] = None,
13+
status_code: Optional[int] = None,
14+
details: Optional[Dict[str, Any]] = None,
15+
source: Optional[Dict[str, Any]] = None,
16+
is_transient: Optional[bool] = None,
17+
) -> None:
18+
super().__init__(message)
19+
self.message = message
20+
self.code = code
21+
self.subcode = subcode
22+
self.status_code = status_code
23+
self.details = details or {}
24+
self.source = source or {}
25+
self.is_transient = is_transient
26+
self.timestamp = _dt.datetime.utcnow().isoformat() + "Z"
27+
28+
def to_dict(self) -> Dict[str, Any]:
29+
return {
30+
"message": self.message,
31+
"code": self.code,
32+
"subcode": self.subcode,
33+
"status_code": self.status_code,
34+
"details": self.details,
35+
"source": self.source,
36+
"is_transient": self.is_transient,
37+
"timestamp": self.timestamp,
38+
}
39+
40+
def __repr__(self) -> str: # pragma: no cover
41+
return f"{self.__class__.__name__}(code={self.code!r}, subcode={self.subcode!r}, message={self.message!r})"
42+
43+
class HttpError(DataverseError):
44+
def __init__(
45+
self,
46+
message: str,
47+
*,
48+
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,
53+
) -> None:
54+
super().__init__(
55+
message,
56+
code="http",
57+
subcode=subcode,
58+
status_code=status_code,
59+
details=details,
60+
source=source,
61+
is_transient=is_transient,
62+
)
63+
64+
__all__ = ["DataverseError", "HttpError"]

src/dataverse_sdk/odata.py

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
from .http import HttpClient
1212
from .odata_upload_files import ODataFileUpload
13+
from .errors import HttpError
14+
from . import error_codes as ec
1315

1416

1517
_GUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
@@ -60,9 +62,50 @@ def _headers(self) -> Dict[str, str]:
6062
"OData-Version": "4.0",
6163
}
6264

63-
def _request(self, method: str, url: str, **kwargs):
65+
def _raw_request(self, method: str, url: str, **kwargs):
6466
return self._http.request(method, url, **kwargs)
6567

68+
def _request(self, method: str, url: str, *, expected: tuple[int, ...] = (200, 201, 202, 204), **kwargs):
69+
"""Execute HTTP request; raise HttpError with structured details on failure.
70+
71+
Returns the raw response for success codes; raises HttpError with extracted
72+
Dataverse error payload fields and correlation identifiers otherwise.
73+
"""
74+
r = self._raw_request(method, url, **kwargs)
75+
if r.status_code in expected:
76+
return r
77+
payload = {}
78+
try:
79+
payload = r.json() if getattr(r, 'text', None) else {}
80+
except Exception:
81+
payload = {}
82+
svc_err = payload.get("error") if isinstance(payload, dict) else None
83+
svc_code = svc_err.get("code") if isinstance(svc_err, dict) else None
84+
svc_msg = svc_err.get("message") if isinstance(svc_err, dict) else None
85+
message = svc_msg or f"HTTP {r.status_code}"
86+
subcode = f"http_{r.status_code}"
87+
88+
headers = getattr(r, 'headers', {}) or {}
89+
details = {
90+
"service_error_code": svc_code,
91+
"body_excerpt": (getattr(r, 'text', '') or '')[:200],
92+
"correlation_id": headers.get("x-ms-correlation-request-id") or headers.get("x-ms-correlation-id"),
93+
"request_id": headers.get("x-ms-client-request-id") or headers.get("request-id"),
94+
"traceparent": headers.get("traceparent"),
95+
}
96+
ra = headers.get("Retry-After")
97+
if ra:
98+
details["retry_after"] = ra
99+
is_transient = r.status_code in (429, 502, 503, 504)
100+
raise HttpError(
101+
message,
102+
subcode=subcode,
103+
status_code=r.status_code,
104+
details=details,
105+
source={"method": method, "url": url},
106+
is_transient=is_transient,
107+
)
108+
66109
# ----------------------------- CRUD ---------------------------------
67110
def _create(self, logical_name: str, data: Union[Dict[str, Any], List[Dict[str, Any]]]) -> Union[str, List[str]]:
68111
"""Create one or many records by logical (singular) name.
@@ -108,7 +151,6 @@ def _create_single(self, entity_set: str, logical_name: str, record: Dict[str, A
108151
url = f"{self.api}/{entity_set}"
109152
headers = self._headers().copy()
110153
r = self._request("post", url, headers=headers, json=record)
111-
r.raise_for_status()
112154

113155
ent_loc = r.headers.get("OData-EntityId") or r.headers.get("OData-EntityID")
114156
if ent_loc:
@@ -144,7 +186,6 @@ def _create_multiple(self, entity_set: str, logical_name: str, records: List[Dic
144186
# The action currently returns only Ids; no need to request representation.
145187
headers = self._headers().copy()
146188
r = self._request("post", url, headers=headers, json=payload)
147-
r.raise_for_status()
148189
try:
149190
body = r.json() if r.text else {}
150191
except ValueError:
@@ -264,7 +305,6 @@ def _update(self, logical_name: str, key: str, data: Dict[str, Any]) -> None:
264305
headers = self._headers().copy()
265306
headers["If-Match"] = "*"
266307
r = self._request("patch", url, headers=headers, json=data)
267-
r.raise_for_status()
268308

269309
def _update_multiple(self, entity_set: str, logical_name: str, records: List[Dict[str, Any]]) -> None:
270310
"""Bulk update existing records via the collection-bound UpdateMultiple action.
@@ -315,7 +355,6 @@ def _update_multiple(self, entity_set: str, logical_name: str, records: List[Dic
315355
url = f"{self.api}/{entity_set}/Microsoft.Dynamics.CRM.UpdateMultiple"
316356
headers = self._headers().copy()
317357
r = self._request("post", url, headers=headers, json=payload)
318-
r.raise_for_status()
319358
# Intentionally ignore response content: no stable contract for IDs across environments.
320359
return None
321360

@@ -325,8 +364,7 @@ def _delete(self, logical_name: str, key: str) -> None:
325364
url = f"{self.api}/{entity_set}{self._format_key(key)}"
326365
headers = self._headers().copy()
327366
headers["If-Match"] = "*"
328-
r = self._request("delete", url, headers=headers)
329-
r.raise_for_status()
367+
self._request("delete", url, headers=headers)
330368

331369
def _get(self, logical_name: str, key: str, select: Optional[str] = None) -> Dict[str, Any]:
332370
"""Retrieve a single record.
@@ -346,7 +384,6 @@ def _get(self, logical_name: str, key: str, select: Optional[str] = None) -> Dic
346384
entity_set = self._entity_set_from_logical(logical_name)
347385
url = f"{self.api}/{entity_set}{self._format_key(key)}"
348386
r = self._request("get", url, headers=self._headers(), params=params)
349-
r.raise_for_status()
350387
return r.json()
351388

352389
def _get_multiple(
@@ -392,7 +429,6 @@ def _get_multiple(
392429

393430
def _do_request(url: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
394431
r = self._request("get", url, headers=headers, params=params)
395-
r.raise_for_status()
396432
try:
397433
return r.json()
398434
except ValueError:
@@ -468,17 +504,6 @@ def _query_sql(self, sql: str) -> list[dict[str, Any]]:
468504
url = f"{self.api}/{entity_set}"
469505
params = {"sql": sql}
470506
r = self._request("get", url, headers=headers, params=params)
471-
try:
472-
r.raise_for_status()
473-
except Exception as e:
474-
# Attach response snippet to aid debugging unsupported SQL patterns
475-
resp_text = None
476-
try:
477-
resp_text = r.text[:500] if getattr(r, 'text', None) else None
478-
except Exception:
479-
pass
480-
detail = f" SQL query failed (status={getattr(r, 'status_code', '?')}): {resp_text}" if resp_text else ""
481-
raise RuntimeError(str(e) + detail) from e
482507
try:
483508
body = r.json()
484509
except ValueError:
@@ -530,7 +555,6 @@ def _entity_set_from_logical(self, logical: str) -> str:
530555
"$filter": f"LogicalName eq '{logical_escaped}'",
531556
}
532557
r = self._request("get", url, headers=self._headers(), params=params)
533-
r.raise_for_status()
534558
try:
535559
body = r.json()
536560
items = body.get("value", []) if isinstance(body, dict) else []
@@ -576,7 +600,6 @@ def _get_entity_by_schema(self, schema_name: str) -> Optional[Dict[str, Any]]:
576600
"$filter": f"SchemaName eq '{schema_escaped}'",
577601
}
578602
r = self._request("get", url, headers=self._headers(), params=params)
579-
r.raise_for_status()
580603
items = r.json().get("value", [])
581604
return items[0] if items else None
582605

@@ -596,7 +619,6 @@ def _create_entity(self, schema_name: str, display_name: str, attributes: List[D
596619
}
597620
headers = self._headers()
598621
r = self._request("post", url, headers=headers, json=payload)
599-
r.raise_for_status()
600622
ent = self._wait_for_entity_ready(schema_name)
601623
if not ent or not ent.get("EntitySetName"):
602624
raise RuntimeError(
@@ -769,7 +791,7 @@ def _optionset_map(self, logical_name: str, attr_logical: str) -> Optional[Dict[
769791
# Retry up to 3 times on 404 (new or not-yet-published attribute metadata). If still 404, raise.
770792
r_type = None
771793
for attempt in range(3):
772-
r_type = self._request("get", url_type, headers=self._headers())
794+
r_type = self._raw_request("get", url_type, headers=self._headers())
773795
if r_type.status_code != 404:
774796
break
775797
if attempt < 2:
@@ -780,7 +802,9 @@ def _optionset_map(self, logical_name: str, attr_logical: str) -> Optional[Dict[
780802
raise RuntimeError(
781803
f"Picklist attribute metadata not found after retries: entity='{logical_name}' attribute='{attr_logical}' (404)"
782804
)
783-
r_type.raise_for_status()
805+
if not (200 <= r_type.status_code < 300):
806+
# Re-issue via _send to raise structured HttpError (rare path)
807+
self._request("get", url_type, headers=self._headers())
784808

785809
body_type = r_type.json()
786810
items = body_type.get("value", []) if isinstance(body_type, dict) else []
@@ -800,14 +824,15 @@ def _optionset_map(self, logical_name: str, attr_logical: str) -> Optional[Dict[
800824
# Step 2 fetch with retries: expanded OptionSet (cast form first)
801825
r_opts = None
802826
for attempt in range(3):
803-
r_opts = self._request("get", cast_url, headers=self._headers())
827+
r_opts = self._raw_request("get", cast_url, headers=self._headers())
804828
if r_opts.status_code != 404:
805829
break
806830
if attempt < 2:
807831
time.sleep(0.4 * (2 ** attempt)) # 0.4s, 0.8s
808832
if r_opts.status_code == 404:
809833
raise RuntimeError(f"Picklist OptionSet metadata not found after retries: entity='{logical_name}' attribute='{attr_logical}' (404)")
810-
r_opts.raise_for_status()
834+
if not (200 <= r_opts.status_code < 300):
835+
self._request("get", cast_url, headers=self._headers())
811836

812837
attr_full = {}
813838
try:
@@ -969,7 +994,6 @@ def _list_tables(self) -> List[Dict[str, Any]]:
969994
"$filter": "IsPrivate eq false"
970995
}
971996
r = self._request("get", url, headers=self._headers(), params=params)
972-
r.raise_for_status()
973997
return r.json().get("value", [])
974998

975999
def _delete_table(self, tablename: str) -> None:
@@ -982,7 +1006,6 @@ def _delete_table(self, tablename: str) -> None:
9821006
url = f"{self.api}/EntityDefinitions({metadata_id})"
9831007
headers = self._headers()
9841008
r = self._request("delete", url, headers=headers)
985-
r.raise_for_status()
9861009

9871010
def _create_table(self, tablename: str, schema: Dict[str, Any]) -> Dict[str, Any]:
9881011
# Accept a friendly name and construct a default schema under 'new_'.

0 commit comments

Comments
 (0)