1010
1111from .http import HttpClient
1212from .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