Skip to content

Commit daa49fb

Browse files
committed
feat: Enhance API responses with trace ID
1 parent 1087d04 commit daa49fb

15 files changed

Lines changed: 569 additions & 112 deletions

‎dataconnect/client.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ def __init__(self, service: DataConnectService) -> None:
3434
"""Initialize the client with an injected service implementation."""
3535
self._service = service
3636

37+
@property
38+
def trace_id(self) -> str | None:
39+
"""Trace ID from the most recent sequential request, when available."""
40+
return getattr(self._service, "trace_id", None)
41+
3742
@classmethod
3843
def connect(
3944
cls,
@@ -67,7 +72,7 @@ def fetch_data(
6772
dataset_uuid: UUID,
6873
first_n_rows: int | None = None,
6974
) -> pd.DataFrame:
70-
"""Fetch data frames for a given dataset UUID."""
75+
"""Fetch data for a given dataset UUID."""
7176
return self._service.fetch_data(dataset_uuid, first_n_rows)
7277

7378
def get_datasets(
@@ -121,8 +126,8 @@ def dry_publish(
121126
122127
Returns:
123128
A :class:`DryPublishResult` containing the server's validation
124-
outcome, including per-field validity flags, error messages, and
125-
an optional ``invalid_records`` DataFrame.
129+
outcome, including per-field validity flags, error messages, an
130+
optional ``invalid_records`` DataFrame.
126131
"""
127132
return self._service.dry_publish(
128133
project_token=project_token,

‎dataconnect/exceptions.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class DataConnectError(Exception):
4848
message: str
4949
timestamp: str | None = None
5050
details: list[ErrorDetail] | None = None
51+
trace_id: str | None = None
5152

5253
def __str__(self) -> str:
5354
lines = [
@@ -63,6 +64,10 @@ def __str__(self) -> str:
6364
for detail in self.details:
6465
lines.append(str(detail))
6566

67+
if self.trace_id is not None:
68+
indent = " " if self.details else ""
69+
lines.append(f"{indent}Trace ID: {self.trace_id}")
70+
6671
return "\n".join(lines)
6772

6873

‎dataconnect/service/default.py‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ class DefaultDataConnectService(DataConnectService):
7474
def __init__(self, transport: Transport) -> None:
7575
self._transport = transport
7676

77+
@property
78+
def trace_id(self) -> str | None:
79+
return getattr(self._transport, "trace_id", None)
80+
7781
# DataConnectService
7882

7983
def get_studies(self, search_study_name: str | None = None) -> StudiesResult:
@@ -92,7 +96,8 @@ def get_studies(self, search_study_name: str | None = None) -> StudiesResult:
9296
request = request.append_body({"search_study_name": search_study_name})
9397

9498
try:
95-
resources = self._transport.list_resources(request)
99+
result = self._transport.list_resources(request)
100+
resources = result
96101
total_records = resources[0].total_records if resources else 0
97102
studies = [resource_to_study(r) for r in resources]
98103
return StudiesResult(total_records=total_records, studies=studies)
@@ -106,7 +111,7 @@ def get_dataset_versions(self, dataset_uuid: UUID) -> list[DatasetVersion]:
106111
dataset_uuid: UUID of the dataset whose versions are requested.
107112
108113
Returns:
109-
A list of :class:`DatasetVersion` objects for the given dataset.
114+
The dataset versions, newest first.
110115
111116
Raises:
112117
ValidationError: If *dataset_uuid* is not a valid UUID (upstream).
@@ -115,14 +120,15 @@ def get_dataset_versions(self, dataset_uuid: UUID) -> list[DatasetVersion]:
115120
request = ResourceQuery(action=_ACTION_LIST_DATASET_VERSIONS).append_body({"dataset_uuid": str(dataset_uuid)})
116121

117122
try:
118-
resources = self._transport.list_resources(request)
123+
result = self._transport.list_resources(request)
119124

120125
# Return Sorted dataset versions in descending order (newest first) based on the dataset_version field.
121-
return sorted(
122-
(resource_to_dataset_version(r) for r in resources),
126+
versions = sorted(
127+
(resource_to_dataset_version(r) for r in result),
123128
key=lambda dv: dv.dataset_version,
124129
reverse=True,
125130
)
131+
return versions
126132
except Exception as ex:
127133
raise translate_error(ex) from ex
128134

@@ -169,7 +175,8 @@ def get_datasets(
169175
)
170176

171177
try:
172-
resources = self._transport.list_resources(request)
178+
result = self._transport.list_resources(request)
179+
resources = result
173180
items = []
174181
for resource in resources:
175182
dataset = resource_to_dataset(resource)
@@ -347,7 +354,7 @@ def get_datetime_formats(
347354
request = DatetimeFormatsRequest(project_token=project_token, format_type=format_type)
348355

349356
try:
350-
raw_formats = self._transport.get_datetime_formats(request)
357+
formats_response = self._transport.get_datetime_formats(request)
351358
except TransportError as ex:
352359
raise translate_error(ex) from ex
353360

@@ -356,7 +363,7 @@ def get_datetime_formats(
356363
format=fmt,
357364
type="datetime" if "HH:mm" in fmt else "date",
358365
)
359-
for fmt in raw_formats
366+
for fmt in formats_response
360367
]
361368

362369
return DatetimeFormatsResult(formats=formats)

‎dataconnect/service/error_handler.py‎

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,26 +43,54 @@ def translate_error(ex: Exception) -> DataConnectError:
4343

4444
if isinstance(ex, TransportAuthenticationError):
4545
return AuthenticationError(
46-
error_code=ex.error_code, message=ex.message, timestamp=ex.timestamp, details=error_details
46+
error_code=ex.error_code,
47+
message=ex.message,
48+
timestamp=ex.timestamp,
49+
details=error_details,
50+
trace_id=ex.trace_id,
4751
)
4852

4953
if isinstance(ex, TransportAuthorizationError):
5054
return AuthorizationError(
51-
error_code=ex.error_code, message=ex.message, timestamp=ex.timestamp, details=error_details
55+
error_code=ex.error_code,
56+
message=ex.message,
57+
timestamp=ex.timestamp,
58+
details=error_details,
59+
trace_id=ex.trace_id,
5260
)
5361

5462
if isinstance(ex, TransportValidationError):
5563
return ValidationError(
56-
error_code=ex.error_code, message=ex.message, timestamp=ex.timestamp, details=error_details
64+
error_code=ex.error_code,
65+
message=ex.message,
66+
timestamp=ex.timestamp,
67+
details=error_details,
68+
trace_id=ex.trace_id,
5769
)
5870

5971
if isinstance(ex, TransportNotFoundError):
6072
return NotFoundError(
61-
error_code=ex.error_code, message=ex.message, timestamp=ex.timestamp, details=error_details
73+
error_code=ex.error_code,
74+
message=ex.message,
75+
timestamp=ex.timestamp,
76+
details=error_details,
77+
trace_id=ex.trace_id,
6278
)
6379

6480
if isinstance(ex, TransportServerError):
65-
return ServerError(error_code=ex.error_code, message=ex.message, timestamp=ex.timestamp, details=error_details)
81+
return ServerError(
82+
error_code=ex.error_code,
83+
message=ex.message,
84+
timestamp=ex.timestamp,
85+
details=error_details,
86+
trace_id=ex.trace_id,
87+
)
6688

6789
# Non-specific transport error
68-
return DataConnectError(error_code=ex.error_code, message=ex.message, timestamp=ex.timestamp, details=error_details)
90+
return DataConnectError(
91+
error_code=ex.error_code,
92+
message=ex.message,
93+
timestamp=ex.timestamp,
94+
details=error_details,
95+
trace_id=ex.trace_id,
96+
)

‎dataconnect/transport/arrow_flight/error_handler.py‎

Lines changed: 90 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,30 @@ def _normalize_enodia_error(error_message: str) -> str:
161161
return error_message
162162

163163

164+
def extract_error_payload(error_message: str) -> dict | None:
165+
"""Find a structured error payload in direct or gRPC-wrapped Flight text."""
166+
normalized_message = _normalize_enodia_error(error_message)
167+
messages = (error_message,) if normalized_message == error_message else (error_message, normalized_message)
168+
169+
for message in messages:
170+
for delimiter in re.finditer(r"::", message):
171+
payload_text = message[delimiter.end() :]
172+
for brace in re.finditer(r"\{", payload_text):
173+
json_part = _extract_json_object(payload_text[brace.start() :])
174+
try:
175+
payload = json.loads(json_part)
176+
except json.JSONDecodeError:
177+
continue
178+
if isinstance(payload, dict):
179+
return payload
180+
181+
return None
182+
183+
164184
_UNKNOWN_ERROR = "Unknown error"
165185

166186

167-
def parse_dataconnect_error(ex: Exception) -> TransportError:
187+
def parse_dataconnect_error(ex: Exception, trace_id: str | None = None) -> TransportError:
168188
"""Parse a raw exception into a typed ``TransportError``.
169189
170190
Extracts a structured ``PREFIX::JSON`` payload from the exception message,
@@ -175,85 +195,88 @@ def parse_dataconnect_error(ex: Exception) -> TransportError:
175195
the message cannot be parsed or does not match the expected format.
176196
"""
177197
try:
198+
if isinstance(ex, TransportError):
199+
if ex.trace_id is None:
200+
ex.trace_id = trace_id
201+
return ex
202+
178203
error_message = str(ex)
179204

180-
# Normalize enodia authentication errors into PREFIX::JSON format
181-
error_message = _normalize_enodia_error(error_message)
182-
183-
if "::" in error_message:
184-
delimiter_pos = error_message.index("::")
185-
json_part_raw = error_message[delimiter_pos + 2 :]
186-
187-
if json_part_raw:
188-
json_part = _extract_json_object(json_part_raw)
189-
190-
error_data: dict = json.loads(json_part)
191-
192-
parsed_details: list[ErrorDetail] | None = None
193-
raw_details = error_data.get("details")
194-
if isinstance(raw_details, list):
195-
standard_keys = {"field", "message", "expected"}
196-
parsed_details = [
197-
ErrorDetail(
198-
field=item.get("field"),
199-
message=item.get("message"),
200-
expected=item.get("expected"),
201-
extra={k: v for k, v in item.items() if k not in standard_keys},
202-
)
203-
for item in raw_details
204-
if isinstance(item, dict)
205-
]
206-
207-
error_code = error_data.get("error_code", "SDK_ERROR")
208-
209-
if error_code.startswith("AUTH_"):
210-
return TransportAuthenticationError(
211-
error_code=error_code,
212-
message=error_data.get("message") or _UNKNOWN_ERROR,
213-
timestamp=error_data.get("timestamp"),
214-
details=parsed_details,
205+
error_data = extract_error_payload(error_message)
206+
if error_data is not None:
207+
parsed_details: list[ErrorDetail] | None = None
208+
raw_details = error_data.get("details")
209+
if isinstance(raw_details, list):
210+
standard_keys = {"field", "message", "expected"}
211+
parsed_details = [
212+
ErrorDetail(
213+
field=item.get("field"),
214+
message=item.get("message"),
215+
expected=item.get("expected"),
216+
extra={k: v for k, v in item.items() if k not in standard_keys},
215217
)
218+
for item in raw_details
219+
if isinstance(item, dict)
220+
]
216221

217-
if error_code.startswith("AUTHZ_"):
218-
return TransportAuthorizationError(
219-
error_code=error_code,
220-
message=error_data.get("message") or _UNKNOWN_ERROR,
221-
timestamp=error_data.get("timestamp"),
222-
details=parsed_details,
223-
)
222+
error_code = error_data.get("error_code", "SDK_ERROR")
223+
payload_trace_id = error_data.get("trace_id")
224+
parsed_trace_id = payload_trace_id if isinstance(payload_trace_id, str) and payload_trace_id else trace_id
224225

225-
if error_code.startswith("VAL_"):
226-
return TransportValidationError(
227-
error_code=error_code,
228-
message=error_data.get("message") or _UNKNOWN_ERROR,
229-
timestamp=error_data.get("timestamp"),
230-
details=parsed_details,
231-
)
226+
if error_code.startswith("AUTH_"):
227+
return TransportAuthenticationError(
228+
error_code=error_code,
229+
message=error_data.get("message") or _UNKNOWN_ERROR,
230+
timestamp=error_data.get("timestamp"),
231+
details=parsed_details,
232+
trace_id=parsed_trace_id,
233+
)
232234

233-
if error_code.startswith("RES_"):
234-
return TransportNotFoundError(
235-
error_code=error_code,
236-
message=error_data.get("message") or _UNKNOWN_ERROR,
237-
timestamp=error_data.get("timestamp"),
238-
details=parsed_details,
239-
)
235+
if error_code.startswith("AUTHZ_"):
236+
return TransportAuthorizationError(
237+
error_code=error_code,
238+
message=error_data.get("message") or _UNKNOWN_ERROR,
239+
timestamp=error_data.get("timestamp"),
240+
details=parsed_details,
241+
trace_id=parsed_trace_id,
242+
)
240243

241-
if error_code.startswith("INT_"):
242-
return TransportServerError(
243-
error_code=error_code,
244-
message=error_data.get("message") or _UNKNOWN_ERROR,
245-
timestamp=error_data.get("timestamp"),
246-
details=parsed_details,
247-
)
244+
if error_code.startswith("VAL_"):
245+
return TransportValidationError(
246+
error_code=error_code,
247+
message=error_data.get("message") or _UNKNOWN_ERROR,
248+
timestamp=error_data.get("timestamp"),
249+
details=parsed_details,
250+
trace_id=parsed_trace_id,
251+
)
248252

249-
return TransportError(
253+
if error_code.startswith("RES_"):
254+
return TransportNotFoundError(
250255
error_code=error_code,
251256
message=error_data.get("message") or _UNKNOWN_ERROR,
252257
timestamp=error_data.get("timestamp"),
253258
details=parsed_details,
259+
trace_id=parsed_trace_id,
254260
)
255261

256-
return TransportError(error_code="SDK_ERROR", message=error_message)
262+
if error_code.startswith("INT_"):
263+
return TransportServerError(
264+
error_code=error_code,
265+
message=error_data.get("message") or _UNKNOWN_ERROR,
266+
timestamp=error_data.get("timestamp"),
267+
details=parsed_details,
268+
trace_id=parsed_trace_id,
269+
)
270+
271+
return TransportError(
272+
error_code=error_code,
273+
message=error_data.get("message") or _UNKNOWN_ERROR,
274+
timestamp=error_data.get("timestamp"),
275+
details=parsed_details,
276+
trace_id=parsed_trace_id,
277+
)
278+
279+
return TransportError(error_code="SDK_ERROR", message=error_message, trace_id=trace_id)
257280

258281
except Exception as ex:
259-
return TransportError(error_code="SDK_ERROR", message=str(ex))
282+
return TransportError(error_code="SDK_ERROR", message=str(ex), trace_id=trace_id)

0 commit comments

Comments
 (0)