Skip to content

Commit 9a82c20

Browse files
committed
feat: Enhanced error handling with convenience properties and helper methods
This commit significantly improves error handling in the Python SDK by adding: 1. Convenience Properties: - Direct access to error code via e.code (instead of e.parsed_exception.code) - Direct access to error message via e.error_message - Direct access to request ID via e.request_id - Direct access to store ID via e.store_id - Direct access to authorization model ID via e.authorization_model_id 2. Operation Context: - Added operation_name parameter to ApiException and all subclasses - Exceptions now track which operation failed (e.g., "Check", "Write") - operation_name propagated through api_client call stack 3. Helper Methods: - is_validation_error() - Check if error is a validation error - is_not_found_error() - Check if error is a not found error - is_authentication_error() - Check if error is an authentication error - is_authorization_error() - Check if error is an authorization error - is_rate_limit_error() - Check if error is a rate limit error - is_server_error() - Check if error is a server error - is_retryable() - Check if error should be retried 4. Enhanced Error Messages: - __str__ method now includes operation name, error code, message, request ID, store ID, and authorization model ID - More readable and informative error output 5. Testing: - Added comprehensive unit tests (17 test cases) - Added integration tests against real OpenFGA server - Docker Compose setup for integration testing - Test documentation and run script Changes are fully backwards compatible - existing code continues to work. Files modified: - openfga_sdk/exceptions.py: Enhanced ApiException and subclasses - openfga_sdk/api_client.py: Added operation_name parameter propagation - openfga_sdk/sync/api_client.py: Added operation_name parameter propagation Files added: - test/error_handling_improvements_test.py: Unit tests - test/integration_error_handling_test.py: Integration tests - docker-compose.integration-test.yml: Test infrastructure - run_integration_tests.sh: Helper script - test/README_INTEGRATION_TESTS.md: Test documentation
1 parent 0604026 commit 9a82c20

8 files changed

Lines changed: 770 additions & 25 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
version: '3.8'
2+
3+
services:
4+
openfga:
5+
image: openfga/openfga:latest
6+
ports:
7+
- "8080:8080"
8+
- "8081:8081"
9+
- "3000:3000"
10+
command: run
11+
environment:
12+
- OPENFGA_DATASTORE_ENGINE=memory
13+
- OPENFGA_LOG_FORMAT=json
14+
healthcheck:
15+
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
16+
interval: 5s
17+
timeout: 3s
18+
retries: 10

openfga_sdk/api_client.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ async def __call_api(
162162
_telemetry_attributes: dict[TelemetryAttribute, str | bool | int | float]
163163
| None = None,
164164
_streaming: bool = False,
165+
_operation_name: str | None = None,
165166
):
166167
self.configuration.is_valid()
167168
config = self.configuration
@@ -316,6 +317,8 @@ async def __call_api(
316317
json.loads(e.body), response_type
317318
)
318319
e.body = None
320+
if _operation_name:
321+
e.operation_name = _operation_name
319322
raise e
320323
except ApiException as e:
321324
e.body = e.body.decode("utf-8")
@@ -326,6 +329,9 @@ async def __call_api(
326329
)
327330
e.body = None
328331

332+
if _operation_name:
333+
e.operation_name = _operation_name
334+
329335
_telemetry_attributes = TelemetryAttributes.fromResponse(
330336
response=e,
331337
credentials=self.configuration.credentials,
@@ -548,6 +554,7 @@ async def call_api(
548554
_telemetry_attributes: dict[TelemetryAttribute, str | bool | int | float]
549555
| None = None,
550556
_streaming: bool = False,
557+
_operation_name: str | None = None,
551558
):
552559
"""Makes the HTTP request (synchronous) and returns deserialized data.
553560
@@ -610,6 +617,7 @@ async def call_api(
610617
_oauth2_client,
611618
_telemetry_attributes,
612619
_streaming,
620+
_operation_name,
613621
)
614622

615623
return self.pool.apply_async(
@@ -634,6 +642,7 @@ async def call_api(
634642
_oauth2_client,
635643
_telemetry_attributes,
636644
_streaming,
645+
_operation_name,
637646
),
638647
)
639648

openfga_sdk/exceptions.py

Lines changed: 89 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ 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__(self, status=None, reason=None, http_resp=None, operation_name=None):
120120
if http_resp:
121121
try:
122122
headers = http_resp.headers.items()
@@ -138,63 +138,127 @@ def __init__(self, status=None, reason=None, http_resp=None):
138138
self._parsed_exception = None
139139
self.header = dict()
140140

141+
self.operation_name = operation_name
142+
141143
def __str__(self):
142-
"""Custom error messages for exception"""
143-
error_message = f"({self.status})\nReason: {self.reason}\n"
144+
parts = []
145+
146+
if self.operation_name:
147+
parts.append(f"Operation: {self.operation_name}")
148+
149+
parts.append(f"Status: {self.status}")
150+
151+
if self.code:
152+
parts.append(f"Error Code: {self.code}")
153+
154+
if self.error_message:
155+
parts.append(f"Message: {self.error_message}")
156+
elif self.reason:
157+
parts.append(f"Reason: {self.reason}")
158+
159+
if self.request_id:
160+
parts.append(f"Request ID: {self.request_id}")
161+
162+
if self.store_id:
163+
parts.append(f"Store ID: {self.store_id}")
164+
165+
if self.authorization_model_id:
166+
parts.append(f"Authorization Model ID: {self.authorization_model_id}")
144167

145168
if self.body:
146-
error_message += f"HTTP response body: {self.body}\n"
169+
parts.append(f"HTTP response body: {self.body}")
147170

148-
return error_message
171+
return "\n".join(parts)
149172

150173
@property
151174
def parsed_exception(self):
152-
"""
153-
Return the parsed body of the exception
154-
"""
155175
return self._parsed_exception
156176

157177
@parsed_exception.setter
158178
def parsed_exception(self, content):
159-
"""
160-
Update the deserialized content
161-
"""
162179
self._parsed_exception = content
163180

181+
@property
182+
def code(self):
183+
if self._parsed_exception and hasattr(self._parsed_exception, "code"):
184+
return self._parsed_exception.code
185+
return None
186+
187+
@property
188+
def error_message(self):
189+
if self._parsed_exception and hasattr(self._parsed_exception, "message"):
190+
return self._parsed_exception.message
191+
return None
192+
193+
@property
194+
def request_id(self):
195+
return self.header.get(FGA_REQUEST_ID)
196+
197+
@property
198+
def store_id(self):
199+
return self.header.get("store_id")
200+
201+
@property
202+
def authorization_model_id(self):
203+
return self.header.get(OPENFGA_AUTHORIZATION_MODEL_ID)
204+
205+
def is_validation_error(self):
206+
return isinstance(self, ValidationException) or (
207+
self.code and "validation" in str(self.code).lower()
208+
)
209+
210+
def is_not_found_error(self):
211+
return isinstance(self, NotFoundException) or self.status == 404
212+
213+
def is_authentication_error(self):
214+
return isinstance(self, (UnauthorizedException, AuthenticationError)) or self.status == 401
215+
216+
def is_authorization_error(self):
217+
return isinstance(self, ForbiddenException) or self.status == 403
218+
219+
def is_rate_limit_error(self):
220+
return isinstance(self, RateLimitExceededError) or self.status == 429
221+
222+
def is_server_error(self):
223+
return isinstance(self, ServiceException) or (self.status and self.status >= 500)
224+
225+
def is_retryable(self):
226+
return self.status in [429, 500, 502, 503, 504]
227+
164228

165229
class NotFoundException(ApiException):
166-
def __init__(self, status=None, reason=None, http_resp=None):
167-
super().__init__(status, reason, http_resp)
230+
def __init__(self, status=None, reason=None, http_resp=None, operation_name=None):
231+
super().__init__(status, reason, http_resp, operation_name)
168232

169233

170234
class UnauthorizedException(ApiException):
171-
def __init__(self, status=None, reason=None, http_resp=None):
172-
super().__init__(status, reason, http_resp)
235+
def __init__(self, status=None, reason=None, http_resp=None, operation_name=None):
236+
super().__init__(status, reason, http_resp, operation_name)
173237

174238

175239
class ForbiddenException(ApiException):
176-
def __init__(self, status=None, reason=None, http_resp=None):
177-
super().__init__(status, reason, http_resp)
240+
def __init__(self, status=None, reason=None, http_resp=None, operation_name=None):
241+
super().__init__(status, reason, http_resp, operation_name)
178242

179243

180244
class ServiceException(ApiException):
181-
def __init__(self, status=None, reason=None, http_resp=None):
182-
super().__init__(status, reason, http_resp)
245+
def __init__(self, status=None, reason=None, http_resp=None, operation_name=None):
246+
super().__init__(status, reason, http_resp, operation_name)
183247

184248

185249
class ValidationException(ApiException):
186-
def __init__(self, status=None, reason=None, http_resp=None):
187-
super().__init__(status, reason, http_resp)
250+
def __init__(self, status=None, reason=None, http_resp=None, operation_name=None):
251+
super().__init__(status, reason, http_resp, operation_name)
188252

189253

190254
class AuthenticationError(ApiException):
191-
def __init__(self, status=None, reason=None, http_resp=None):
192-
super().__init__(status, reason, http_resp)
255+
def __init__(self, status=None, reason=None, http_resp=None, operation_name=None):
256+
super().__init__(status, reason, http_resp, operation_name)
193257

194258

195259
class RateLimitExceededError(ApiException):
196-
def __init__(self, status=None, reason=None, http_resp=None):
197-
super().__init__(status, reason, http_resp)
260+
def __init__(self, status=None, reason=None, http_resp=None, operation_name=None):
261+
super().__init__(status, reason, http_resp, operation_name)
198262

199263

200264
def render_path(path_to_item):

openfga_sdk/sync/api_client.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ def __call_api(
161161
_telemetry_attributes: dict[TelemetryAttribute, str | bool | int | float]
162162
| None = None,
163163
_streaming: bool = False,
164+
_operation_name: str | None = None,
164165
):
165166
self.configuration.is_valid()
166167
config = self.configuration
@@ -314,6 +315,8 @@ def __call_api(
314315
json.loads(e.body), response_type
315316
)
316317
e.body = None
318+
if _operation_name:
319+
e.operation_name = _operation_name
317320
raise e
318321
except ApiException as e:
319322
e.body = e.body.decode("utf-8")
@@ -324,6 +327,9 @@ def __call_api(
324327
)
325328
e.body = None
326329

330+
if _operation_name:
331+
e.operation_name = _operation_name
332+
327333
_telemetry_attributes = TelemetryAttributes.fromResponse(
328334
response=e,
329335
credentials=self.configuration.credentials,
@@ -546,6 +552,7 @@ def call_api(
546552
_telemetry_attributes: dict[TelemetryAttribute, str | bool | int | float]
547553
| None = None,
548554
_streaming: bool = False,
555+
_operation_name: str | None = None,
549556
):
550557
"""Makes the HTTP request (synchronous) and returns deserialized data.
551558
@@ -608,6 +615,7 @@ def call_api(
608615
_oauth2_client,
609616
_telemetry_attributes,
610617
_streaming,
618+
_operation_name,
611619
)
612620

613621
return self.pool.apply_async(
@@ -632,6 +640,7 @@ def call_api(
632640
_oauth2_client,
633641
_telemetry_attributes,
634642
_streaming,
643+
_operation_name,
635644
),
636645
)
637646

run_integration_tests.sh

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
#!/bin/bash
2+
3+
set -e
4+
5+
echo "========================================="
6+
echo "OpenFGA Python SDK Integration Tests"
7+
echo "========================================="
8+
echo ""
9+
10+
if ! command -v docker &> /dev/null; then
11+
echo "Error: Docker is not installed or not in PATH"
12+
exit 1
13+
fi
14+
15+
echo "Step 1: Starting OpenFGA server..."
16+
docker compose -f docker-compose.integration-test.yml up -d
17+
18+
echo "Step 2: Waiting for server to be healthy..."
19+
timeout=60
20+
elapsed=0
21+
while [ $elapsed -lt $timeout ]; do
22+
if docker compose -f docker-compose.integration-test.yml ps | grep -q "healthy"; then
23+
echo "Server is healthy!"
24+
break
25+
fi
26+
sleep 2
27+
elapsed=$((elapsed + 2))
28+
echo "Waiting... ($elapsed/$timeout seconds)"
29+
done
30+
31+
if [ $elapsed -ge $timeout ]; then
32+
echo "Error: Server did not become healthy in time"
33+
docker compose -f docker-compose.integration-test.yml logs
34+
docker compose -f docker-compose.integration-test.yml down
35+
exit 1
36+
fi
37+
38+
echo ""
39+
echo "Step 3: Running integration tests..."
40+
python -m pytest test/integration_error_handling_test.py -v -s || {
41+
echo ""
42+
echo "Tests failed. Cleaning up..."
43+
docker compose -f docker-compose.integration-test.yml down
44+
exit 1
45+
}
46+
47+
echo ""
48+
echo "Step 4: Cleaning up..."
49+
docker compose -f docker-compose.integration-test.yml down
50+
51+
echo ""
52+
echo "========================================="
53+
echo "All integration tests passed!"
54+
echo "========================================="

0 commit comments

Comments
 (0)