Skip to content

Commit 5227a6a

Browse files
authored
feat: improve error messaging (#245)
* feat: improve errors and relay err codes and msgs * fix: readme * fix: ruff lint * feat: update * feat: address copilot comments
1 parent 0604026 commit 5227a6a

File tree

6 files changed

+1066
-23
lines changed

6 files changed

+1066
-23
lines changed

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,6 +1407,29 @@ Class | Method | HTTP request | Description
14071407

14081408
This SDK supports producing metrics that can be consumed as part of an [OpenTelemetry](https://opentelemetry.io/) setup. For more information, please see [the documentation](https://github.com/openfga/python-sdk/blob/main/docs/opentelemetry.md)
14091409

1410+
### Error Handling
1411+
1412+
The SDK provides comprehensive error handling with detailed error information and convenient helper methods.
1413+
1414+
Key features:
1415+
- Operation context in error messages (e.g., `[write]`, `[check]`)
1416+
- Detailed error codes and messages from the API
1417+
- Helper methods for error categorization (`is_validation_error()`, `is_retryable()`, etc.)
1418+
1419+
```python
1420+
from openfga_sdk.exceptions import ApiException
1421+
1422+
try:
1423+
await client.write([tuple])
1424+
except ApiException as e:
1425+
print(f"Error: {e}") # [write] HTTP 400 type 'invalid_type' not found (validation_error) [request-id: abc-123]
1426+
1427+
if e.is_validation_error():
1428+
print(f"Validation error: {e.error_message}")
1429+
elif e.is_retryable():
1430+
print(f"Temporary error - retrying... (Request ID: {e.request_id})")
1431+
```
1432+
14101433
## Contributing
14111434

14121435
See [CONTRIBUTING](./CONTRIBUTING.md) for details.

openfga_sdk/api_client.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,16 @@ async def __call_api(
316316
json.loads(e.body), response_type
317317
)
318318
e.body = None
319+
if (
320+
isinstance(e, ApiException)
321+
and TelemetryAttributes.fga_client_request_method
322+
in _telemetry_attributes
323+
):
324+
operation_name = _telemetry_attributes.get(
325+
TelemetryAttributes.fga_client_request_method
326+
)
327+
if isinstance(operation_name, str):
328+
e.operation_name = operation_name.lower()
319329
raise e
320330
except ApiException as e:
321331
e.body = e.body.decode("utf-8")
@@ -347,7 +357,18 @@ async def __call_api(
347357
attributes=_telemetry_attributes,
348358
configuration=self.configuration.telemetry,
349359
)
350-
raise e
360+
361+
if (
362+
isinstance(e, ApiException)
363+
and TelemetryAttributes.fga_client_request_method
364+
in _telemetry_attributes
365+
):
366+
operation_name = _telemetry_attributes.get(
367+
TelemetryAttributes.fga_client_request_method
368+
)
369+
if isinstance(operation_name, str):
370+
e.operation_name = operation_name.lower()
371+
raise
351372

352373
self.last_response = response_data
353374

openfga_sdk/exceptions.py

Lines changed: 170 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@ def __init__(self, msg, path_to_item=None):
116116

117117

118118
class ApiException(OpenApiException):
119-
def __init__(self, status=None, reason=None, http_resp=None):
119+
def __init__(
120+
self, status=None, reason=None, http_resp=None, *, operation_name=None
121+
):
120122
if http_resp:
121123
try:
122124
headers = http_resp.headers.items()
@@ -138,14 +140,37 @@ def __init__(self, status=None, reason=None, http_resp=None):
138140
self._parsed_exception = None
139141
self.header = dict()
140142

143+
self.operation_name = operation_name
144+
141145
def __str__(self):
142-
"""Custom error messages for exception"""
143-
error_message = f"({self.status})\nReason: {self.reason}\n"
146+
"""
147+
Format error with operation context and structured details.
148+
Returns formatted string like:
149+
[write] HTTP 400 type 'invalid_type' not found (validation_error) [request-id: abc-123]
150+
"""
151+
parts = []
152+
153+
# Add operation context
154+
if self.operation_name:
155+
parts.append(f"[{self.operation_name}]")
156+
157+
# Add error type/status
158+
if self.status:
159+
parts.append(f"HTTP {self.status}")
160+
161+
# Add error message (parsed or reason)
162+
if self.error_message:
163+
parts.append(self.error_message)
164+
165+
# Add error code in parentheses
166+
if self.code:
167+
parts.append(f"({self.code})")
144168

145-
if self.body:
146-
error_message += f"HTTP response body: {self.body}\n"
169+
# Add request ID for debugging
170+
if self.request_id:
171+
parts.append(f"[request-id: {self.request_id}]")
147172

148-
return error_message
173+
return " ".join(parts) if parts else "Unknown API error"
149174

150175
@property
151176
def parsed_exception(self):
@@ -161,40 +186,165 @@ def parsed_exception(self, content):
161186
"""
162187
self._parsed_exception = content
163188

189+
@property
190+
def code(self):
191+
"""
192+
Get the error code from the parsed exception.
193+
194+
Returns:
195+
Error code string (e.g., "validation_error") or None
196+
"""
197+
if self._parsed_exception and hasattr(self._parsed_exception, "code"):
198+
code_value = self._parsed_exception.code
199+
# Handle enum types
200+
if hasattr(code_value, "value"):
201+
return code_value.value
202+
return str(code_value) if code_value is not None else None
203+
return None
204+
205+
@property
206+
def error_message(self):
207+
"""
208+
Get the human-readable error message.
209+
210+
Returns:
211+
Error message from API or HTTP reason phrase
212+
"""
213+
if self._parsed_exception and hasattr(self._parsed_exception, "message"):
214+
message = self._parsed_exception.message
215+
if message:
216+
return message
217+
return self.reason or "Unknown error"
218+
219+
@property
220+
def request_id(self):
221+
"""
222+
Get the request ID for debugging and support.
223+
224+
Returns:
225+
FGA request ID from response headers or None
226+
"""
227+
if not self.header:
228+
return None
229+
# HTTP headers are case-insensitive, try different cases
230+
for key in self.header:
231+
if key.lower() == FGA_REQUEST_ID:
232+
return self.header[key]
233+
return None
234+
235+
def is_validation_error(self):
236+
"""
237+
Check if this is a validation error.
238+
239+
Returns:
240+
True if error code indicates validation failure
241+
"""
242+
return isinstance(self, ValidationException) or (
243+
self.code and "validation" in self.code.lower()
244+
)
245+
246+
def is_not_found_error(self):
247+
"""
248+
Check if this is a not found (404) error.
249+
250+
Returns:
251+
True if HTTP status is 404
252+
"""
253+
return isinstance(self, NotFoundException) or self.status == 404
254+
255+
def is_authentication_error(self):
256+
"""
257+
Check if this is an authentication (401) error.
258+
259+
Returns:
260+
True if HTTP status is 401
261+
"""
262+
return self.status == 401
263+
264+
def is_rate_limit_error(self):
265+
"""
266+
Check if this is a rate limit (429) error.
267+
268+
Returns:
269+
True if HTTP status is 429 or error code indicates rate limiting
270+
"""
271+
return self.status == 429 or (self.code and "rate_limit" in self.code.lower())
272+
273+
def is_retryable(self):
274+
"""
275+
Check if this error should be retried.
276+
277+
Returns:
278+
True if error is temporary and retrying may succeed
279+
"""
280+
return self.status in [429, 500, 502, 503, 504] if self.status else False
281+
282+
def is_client_error(self):
283+
"""
284+
Check if this is a client error (4xx).
285+
286+
Returns:
287+
True if HTTP status is in 400-499 range
288+
"""
289+
return 400 <= self.status < 500 if self.status else False
290+
291+
def is_server_error(self):
292+
"""
293+
Check if this is a server error (5xx).
294+
295+
Returns:
296+
True if HTTP status is in 500-599 range
297+
"""
298+
return 500 <= self.status < 600 if self.status else False
299+
164300

165301
class NotFoundException(ApiException):
166-
def __init__(self, status=None, reason=None, http_resp=None):
167-
super().__init__(status, reason, http_resp)
302+
def __init__(
303+
self, status=None, reason=None, http_resp=None, *, operation_name=None
304+
):
305+
super().__init__(status, reason, http_resp, operation_name=operation_name)
168306

169307

170308
class UnauthorizedException(ApiException):
171-
def __init__(self, status=None, reason=None, http_resp=None):
172-
super().__init__(status, reason, http_resp)
309+
def __init__(
310+
self, status=None, reason=None, http_resp=None, *, operation_name=None
311+
):
312+
super().__init__(status, reason, http_resp, operation_name=operation_name)
173313

174314

175315
class ForbiddenException(ApiException):
176-
def __init__(self, status=None, reason=None, http_resp=None):
177-
super().__init__(status, reason, http_resp)
316+
def __init__(
317+
self, status=None, reason=None, http_resp=None, *, operation_name=None
318+
):
319+
super().__init__(status, reason, http_resp, operation_name=operation_name)
178320

179321

180322
class ServiceException(ApiException):
181-
def __init__(self, status=None, reason=None, http_resp=None):
182-
super().__init__(status, reason, http_resp)
323+
def __init__(
324+
self, status=None, reason=None, http_resp=None, *, operation_name=None
325+
):
326+
super().__init__(status, reason, http_resp, operation_name=operation_name)
183327

184328

185329
class ValidationException(ApiException):
186-
def __init__(self, status=None, reason=None, http_resp=None):
187-
super().__init__(status, reason, http_resp)
330+
def __init__(
331+
self, status=None, reason=None, http_resp=None, *, operation_name=None
332+
):
333+
super().__init__(status, reason, http_resp, operation_name=operation_name)
188334

189335

190336
class AuthenticationError(ApiException):
191-
def __init__(self, status=None, reason=None, http_resp=None):
192-
super().__init__(status, reason, http_resp)
337+
def __init__(
338+
self, status=None, reason=None, http_resp=None, *, operation_name=None
339+
):
340+
super().__init__(status, reason, http_resp, operation_name=operation_name)
193341

194342

195343
class RateLimitExceededError(ApiException):
196-
def __init__(self, status=None, reason=None, http_resp=None):
197-
super().__init__(status, reason, http_resp)
344+
def __init__(
345+
self, status=None, reason=None, http_resp=None, *, operation_name=None
346+
):
347+
super().__init__(status, reason, http_resp, operation_name=operation_name)
198348

199349

200350
def render_path(path_to_item):

openfga_sdk/sync/api_client.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,18 @@ def __call_api(
314314
json.loads(e.body), response_type
315315
)
316316
e.body = None
317-
raise e
317+
# Set operation name from telemetry attributes
318+
if (
319+
isinstance(e, ApiException)
320+
and TelemetryAttributes.fga_client_request_method
321+
in _telemetry_attributes
322+
):
323+
operation_name = _telemetry_attributes.get(
324+
TelemetryAttributes.fga_client_request_method
325+
)
326+
if isinstance(operation_name, str):
327+
e.operation_name = operation_name.lower()
328+
raise
318329
except ApiException as e:
319330
e.body = e.body.decode("utf-8")
320331
response_type = response_types_map.get(e.status, None)
@@ -345,7 +356,19 @@ def __call_api(
345356
attributes=_telemetry_attributes,
346357
configuration=self.configuration.telemetry,
347358
)
348-
raise e
359+
360+
# Set operation name from telemetry attributes
361+
if (
362+
isinstance(e, ApiException)
363+
and TelemetryAttributes.fga_client_request_method
364+
in _telemetry_attributes
365+
):
366+
operation_name = _telemetry_attributes.get(
367+
TelemetryAttributes.fga_client_request_method
368+
)
369+
if isinstance(operation_name, str):
370+
e.operation_name = operation_name.lower()
371+
raise
349372

350373
self.last_response = response_data
351374

0 commit comments

Comments
 (0)