Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1407,6 +1407,29 @@ Class | Method | HTTP request | Description

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)

### Error Handling

The SDK provides comprehensive error handling with detailed error information and convenient helper methods.

Key features:
- Operation context in error messages (e.g., `[write]`, `[check]`)
- Detailed error codes and messages from the API
- Helper methods for error categorization (`is_validation_error()`, `is_retryable()`, etc.)

```python
from openfga_sdk.exceptions import ApiException

try:
await client.write([tuple])
except ApiException as e:
print(f"Error: {e}") # [write] HTTP 400 type 'invalid_type' not found (validation_error) [request-id: abc-123]

if e.is_validation_error():
print(f"Validation error: {e.error_message}")
elif e.is_retryable():
print(f"Temporary error - retrying... (Request ID: {e.request_id})")
```

## Contributing

See [CONTRIBUTING](./CONTRIBUTING.md) for details.
Expand Down
23 changes: 22 additions & 1 deletion openfga_sdk/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,16 @@ async def __call_api(
json.loads(e.body), response_type
)
e.body = None
if (
isinstance(e, ApiException)
and TelemetryAttributes.fga_client_request_method
in _telemetry_attributes
):
operation_name = _telemetry_attributes.get(
TelemetryAttributes.fga_client_request_method
)
if isinstance(operation_name, str):
e.operation_name = operation_name.lower()
raise e
except ApiException as e:
e.body = e.body.decode("utf-8")
Expand Down Expand Up @@ -347,7 +357,18 @@ async def __call_api(
attributes=_telemetry_attributes,
configuration=self.configuration.telemetry,
)
raise e

if (
isinstance(e, ApiException)
and TelemetryAttributes.fga_client_request_method
in _telemetry_attributes
):
operation_name = _telemetry_attributes.get(
TelemetryAttributes.fga_client_request_method
)
if isinstance(operation_name, str):
e.operation_name = operation_name.lower()
raise

self.last_response = response_data

Expand Down
190 changes: 170 additions & 20 deletions openfga_sdk/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ def __init__(self, msg, path_to_item=None):


class ApiException(OpenApiException):
def __init__(self, status=None, reason=None, http_resp=None):
def __init__(
self, status=None, reason=None, http_resp=None, *, operation_name=None
):
if http_resp:
try:
headers = http_resp.headers.items()
Expand All @@ -138,14 +140,37 @@ def __init__(self, status=None, reason=None, http_resp=None):
self._parsed_exception = None
self.header = dict()

self.operation_name = operation_name

def __str__(self):
"""Custom error messages for exception"""
error_message = f"({self.status})\nReason: {self.reason}\n"
"""
Format error with operation context and structured details.
Returns formatted string like:
[write] HTTP 400 type 'invalid_type' not found (validation_error) [request-id: abc-123]
"""
parts = []

# Add operation context
if self.operation_name:
parts.append(f"[{self.operation_name}]")

# Add error type/status
if self.status:
parts.append(f"HTTP {self.status}")

# Add error message (parsed or reason)
if self.error_message:
parts.append(self.error_message)

# Add error code in parentheses
if self.code:
parts.append(f"({self.code})")

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

return error_message
return " ".join(parts) if parts else "Unknown API error"

@property
def parsed_exception(self):
Expand All @@ -161,40 +186,165 @@ def parsed_exception(self, content):
"""
self._parsed_exception = content

@property
def code(self):
"""
Get the error code from the parsed exception.

Returns:
Error code string (e.g., "validation_error") or None
"""
if self._parsed_exception and hasattr(self._parsed_exception, "code"):
code_value = self._parsed_exception.code
# Handle enum types
if hasattr(code_value, "value"):
return code_value.value
return str(code_value) if code_value is not None else None
return None

@property
def error_message(self):
"""
Get the human-readable error message.

Returns:
Error message from API or HTTP reason phrase
"""
if self._parsed_exception and hasattr(self._parsed_exception, "message"):
message = self._parsed_exception.message
if message:
return message
return self.reason or "Unknown error"

@property
def request_id(self):
"""
Get the request ID for debugging and support.

Returns:
FGA request ID from response headers or None
"""
if not self.header:
return None
# HTTP headers are case-insensitive, try different cases
for key in self.header:
if key.lower() == FGA_REQUEST_ID:
return self.header[key]
return None

def is_validation_error(self):
"""
Check if this is a validation error.

Returns:
True if error code indicates validation failure
"""
return isinstance(self, ValidationException) or (
self.code and "validation" in self.code.lower()
)

def is_not_found_error(self):
"""
Check if this is a not found (404) error.

Returns:
True if HTTP status is 404
"""
return isinstance(self, NotFoundException) or self.status == 404

def is_authentication_error(self):
"""
Check if this is an authentication (401) error.

Returns:
True if HTTP status is 401
"""
return self.status == 401

def is_rate_limit_error(self):
"""
Check if this is a rate limit (429) error.

Returns:
True if HTTP status is 429 or error code indicates rate limiting
"""
return self.status == 429 or (self.code and "rate_limit" in self.code.lower())

def is_retryable(self):
"""
Check if this error should be retried.

Returns:
True if error is temporary and retrying may succeed
"""
return self.status in [429, 500, 502, 503, 504] if self.status else False

def is_client_error(self):
"""
Check if this is a client error (4xx).

Returns:
True if HTTP status is in 400-499 range
"""
return 400 <= self.status < 500 if self.status else False

def is_server_error(self):
"""
Check if this is a server error (5xx).

Returns:
True if HTTP status is in 500-599 range
"""
return 500 <= self.status < 600 if self.status else False


class NotFoundException(ApiException):
def __init__(self, status=None, reason=None, http_resp=None):
super().__init__(status, reason, http_resp)
def __init__(
self, status=None, reason=None, http_resp=None, *, operation_name=None
):
super().__init__(status, reason, http_resp, operation_name=operation_name)


class UnauthorizedException(ApiException):
def __init__(self, status=None, reason=None, http_resp=None):
super().__init__(status, reason, http_resp)
def __init__(
self, status=None, reason=None, http_resp=None, *, operation_name=None
):
super().__init__(status, reason, http_resp, operation_name=operation_name)


class ForbiddenException(ApiException):
def __init__(self, status=None, reason=None, http_resp=None):
super().__init__(status, reason, http_resp)
def __init__(
self, status=None, reason=None, http_resp=None, *, operation_name=None
):
super().__init__(status, reason, http_resp, operation_name=operation_name)


class ServiceException(ApiException):
def __init__(self, status=None, reason=None, http_resp=None):
super().__init__(status, reason, http_resp)
def __init__(
self, status=None, reason=None, http_resp=None, *, operation_name=None
):
super().__init__(status, reason, http_resp, operation_name=operation_name)


class ValidationException(ApiException):
def __init__(self, status=None, reason=None, http_resp=None):
super().__init__(status, reason, http_resp)
def __init__(
self, status=None, reason=None, http_resp=None, *, operation_name=None
):
super().__init__(status, reason, http_resp, operation_name=operation_name)


class AuthenticationError(ApiException):
def __init__(self, status=None, reason=None, http_resp=None):
super().__init__(status, reason, http_resp)
def __init__(
self, status=None, reason=None, http_resp=None, *, operation_name=None
):
super().__init__(status, reason, http_resp, operation_name=operation_name)


class RateLimitExceededError(ApiException):
def __init__(self, status=None, reason=None, http_resp=None):
super().__init__(status, reason, http_resp)
def __init__(
self, status=None, reason=None, http_resp=None, *, operation_name=None
):
super().__init__(status, reason, http_resp, operation_name=operation_name)


def render_path(path_to_item):
Expand Down
27 changes: 25 additions & 2 deletions openfga_sdk/sync/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,18 @@ def __call_api(
json.loads(e.body), response_type
)
e.body = None
raise e
# Set operation name from telemetry attributes
if (
isinstance(e, ApiException)
and TelemetryAttributes.fga_client_request_method
in _telemetry_attributes
):
operation_name = _telemetry_attributes.get(
TelemetryAttributes.fga_client_request_method
)
if isinstance(operation_name, str):
e.operation_name = operation_name.lower()
raise
except ApiException as e:
e.body = e.body.decode("utf-8")
response_type = response_types_map.get(e.status, None)
Expand Down Expand Up @@ -345,7 +356,19 @@ def __call_api(
attributes=_telemetry_attributes,
configuration=self.configuration.telemetry,
)
raise e

# Set operation name from telemetry attributes
if (
isinstance(e, ApiException)
and TelemetryAttributes.fga_client_request_method
in _telemetry_attributes
):
operation_name = _telemetry_attributes.get(
TelemetryAttributes.fga_client_request_method
)
if isinstance(operation_name, str):
e.operation_name = operation_name.lower()
raise

self.last_response = response_data

Expand Down
Loading