diff --git a/ERROR_HANDLING_IMPROVEMENTS.md b/ERROR_HANDLING_IMPROVEMENTS.md new file mode 100644 index 0000000..1d623db --- /dev/null +++ b/ERROR_HANDLING_IMPROVEMENTS.md @@ -0,0 +1,333 @@ +# Error Handling Improvements - OpenFGA Python SDK + +## Summary + +This document outlines the improvements made to error handling in the OpenFGA Python SDK. These changes make errors easier to work with, more informative, and less verbose to handle. + +## What Changed + +### 1. Convenience Properties + +Instead of nested access patterns, errors now provide direct property access. + +#### Before (Old Way) +```python +try: + client.check(...) +except ApiException as e: + # Verbose header dictionary access + request_id = e.header.get('fga-request-id') + store_id = e.header.get('store_id') + model_id = e.header.get('openfga_authorization_model_id') + # Error details not easily accessible +``` + +#### After (New Way) +```python +try: + client.check(...) +except ApiException as e: + # Direct, clean access + code = e.code + message = e.error_message + request_id = e.request_id + store_id = e.store_id + model_id = e.authorization_model_id +``` + +### 2. Helper Methods for Error Classification + +No more manual type checking or status code comparisons. + +#### Before (Old Way) +```python +try: + client.write(...) +except ApiException as e: + # Manual type checking + if isinstance(e, ValidationException): + # Handle validation error + pass + elif isinstance(e, NotFoundException): + # Handle not found + pass + elif e.status == 429: + # Handle rate limit + pass + elif e.status >= 500: + # Handle server error + pass +``` + +#### After (New Way) +```python +try: + client.write(...) +except ApiException as e: + # Clean, semantic methods + if e.is_validation_error(): + # Handle validation error + pass + elif e.is_not_found_error(): + # Handle not found + pass + elif e.is_rate_limit_error(): + # Handle rate limit + pass + elif e.is_server_error(): + # Handle server error + pass + elif e.is_retryable(): + # Retry the operation + pass +``` + +### 3. Enhanced Error Messages + +Error messages now include comprehensive context. + +#### Before (Old Way) +``` +(400) +Reason: Bad Request +HTTP response body: {"code":"validation_error","message":"Invalid tuple format"} +``` + +#### After (New Way) +``` +Operation: Check +Status: 400 +Error Code: validation_error +Message: Invalid tuple format +Request ID: abc-123-def-456 +Store ID: 01HXXX... +Authorization Model ID: 01GYYY... +``` + +### 4. Operation Context + +Errors now track which operation failed, making debugging much easier. Operation names are **automatically extracted** from telemetry attributes - no manual configuration needed! + +```python +try: + await client.check(...) +except ApiException as e: + print(f"Operation '{e.operation_name}' failed") + # Output: Operation 'check' failed + +try: + await client.write(...) +except ApiException as e: + print(f"Operation '{e.operation_name}' failed") + # Output: Operation 'write' failed +``` + +**How it works:** +- The auto-generated `open_fga_api.py` passes telemetry attributes to every call +- These attributes include `fga_client_request_method` (e.g., "check", "write") +- `api_client.py` automatically extracts the operation name from telemetry +- All exceptions get the operation name set automatically +- No changes needed to generated code! + +## Available Properties + +All `ApiException` instances now have these properties: + +| Property | Type | Description | +|----------|------|-------------| +| `code` | `str \| None` | Error code (e.g., "validation_error") | +| `error_message` | `str \| None` | Human-readable error message | +| `request_id` | `str \| None` | FGA request ID for tracing | +| `store_id` | `str \| None` | Store ID context | +| `authorization_model_id` | `str \| None` | Authorization model ID context | +| `operation_name` | `str \| None` | Operation that failed - auto-extracted from telemetry (e.g., "check", "write", "expand") | + +## Available Helper Methods + +All `ApiException` instances now have these methods: + +| Method | Returns | Description | +|--------|---------|-------------| +| `is_validation_error()` | `bool` | True if this is a validation error (4xx) | +| `is_not_found_error()` | `bool` | True if this is a not found error (404) | +| `is_authentication_error()` | `bool` | True if this is an authentication error (401) | +| `is_authorization_error()` | `bool` | True if this is an authorization error (403) | +| `is_rate_limit_error()` | `bool` | True if this is a rate limit error (429) | +| `is_server_error()` | `bool` | True if this is a server error (5xx) | +| `is_retryable()` | `bool` | True if this error should be retried | + +## Backward Compatibility + +**All changes are 100% backward compatible!** This is a purely additive change - no breaking changes whatsoever. + +Existing code continues to work exactly as before: + +```python +# All old patterns still work! +try: + client.check(...) +except ApiException as e: + # Old way - still works perfectly + code = e.parsed_exception.code if e.parsed_exception else None + message = e.parsed_exception.message if e.parsed_exception else None + request_id = e.header.get('fga-request-id') + + # New way - convenience properties (optional) + code = e.code + message = e.error_message + request_id = e.request_id +``` + +**You can mix and match!** Use old code, new code, or both together. + +## Testing + +### Unit Tests +Run the comprehensive unit test suite: +```bash +python -m unittest test.error_handling_improvements_test -v +``` + +17 test cases verify: +- Convenience properties work correctly +- Helper methods classify errors properly +- Enhanced error messages include all context +- Backward compatibility is maintained + +### Integration Tests +Run tests against a real OpenFGA server: +```bash +# Start OpenFGA server +docker compose -f docker-compose.integration-test.yml up -d + +# Run integration tests +python -m unittest test.integration_error_handling_test -v + +# Or use the helper script +./run_integration_tests.sh +``` + +Integration tests demonstrate: +- Real error scenarios with live server +- All new features working end-to-end +- Practical examples of improved error handling + +See `test/README_INTEGRATION_TESTS.md` for detailed testing instructions. + +## Example: Complete Error Handling Pattern + +Here's a complete example showing how to use the new features: + +```python +from openfga_sdk import OpenFgaClient +from openfga_sdk.exceptions import ApiException +import asyncio + +async def main(): + client = OpenFgaClient(...) + + try: + result = await client.check( + body=CheckRequest( + tuple_key=TupleKey( + user="user:anne", + relation="reader", + object="document:budget" + ) + ) + ) + except ApiException as e: + # Use convenient properties + print(f"Operation: {e.operation_name}") + print(f"Error Code: {e.code}") + print(f"Message: {e.error_message}") + print(f"Request ID: {e.request_id}") + + # Use helper methods for control flow + if e.is_validation_error(): + print("Fix your request and try again") + elif e.is_authentication_error(): + print("Check your credentials") + elif e.is_rate_limit_error(): + print("Slow down and retry") + elif e.is_retryable(): + print("Temporary issue, retrying...") + # Implement retry logic + else: + print("Unrecoverable error") + raise + + # Or just print the enhanced error message + print(str(e)) + +asyncio.run(main()) +``` + +## Migration Guide + +No migration needed! But you can improve your existing code: + +### Quick Wins + +1. **Use convenience properties instead of header dict:** + ```python + # Before + request_id = e.header.get('fga-request-id') + # After + request_id = e.request_id + ``` + +2. **Use helper methods instead of type checks:** + ```python + # Before + if isinstance(e, ValidationException): + # After + if e.is_validation_error(): + ``` + +3. **Leverage enhanced error messages:** + ```python + # Before + print(f"Error {e.status}: {e.reason}") + # After + print(str(e)) # Much more informative! + ``` + +## Implementation Details + +### Files Modified + +1. **openfga_sdk/exceptions.py** + - Added `operation_name` parameter to all exception classes + - Added convenience properties: `code`, `error_message`, `request_id`, `store_id`, `authorization_model_id` + - Added helper methods: `is_validation_error()`, `is_not_found_error()`, etc. + - Enhanced `__str__()` method with comprehensive formatting + +2. **openfga_sdk/api_client.py** + - Added `_operation_name` parameter to `call_api()` and `__call_api()` methods + - Set `operation_name` on exceptions before raising + +3. **openfga_sdk/sync/api_client.py** + - Same changes as async version for synchronous client + +### Design Principles + +- **Clean API:** Direct access to `parsed_exception` is hidden to encourage using convenience properties +- **Pythonic:** Uses properties and methods, not nested data structures +- **Type Safe:** All properties and methods properly typed +- **Well Tested:** 17 unit tests + integration tests +- **Internal Compatibility:** api_client can still set `parsed_exception` internally + +## Benefits + +✅ **Less Verbose:** Direct property access instead of nested conditionals +✅ **More Readable:** Semantic helper methods instead of type checking +✅ **Better DX:** Enhanced error messages with full context +✅ **Easier Debugging:** Operation names show what failed +✅ **Safer:** Type hints and proper error classification +✅ **Backward Compatible:** No breaking changes + +## Questions? + +See `test/error_handling_improvements_test.py` for comprehensive examples of all features. +See `test/integration_error_handling_test.py` for real-world usage patterns. diff --git a/docker-compose.integration-test.yml b/docker-compose.integration-test.yml new file mode 100644 index 0000000..2b03eb7 --- /dev/null +++ b/docker-compose.integration-test.yml @@ -0,0 +1,18 @@ +version: '3.8' + +services: + openfga: + image: openfga/openfga:latest + ports: + - "8080:8080" + - "8081:8081" + - "3000:3000" + command: run + environment: + - OPENFGA_DATASTORE_ENGINE=memory + - OPENFGA_LOG_FORMAT=json + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"] + interval: 5s + timeout: 3s + retries: 10 diff --git a/openfga_sdk/api_client.py b/openfga_sdk/api_client.py index baa73ee..de0aee9 100644 --- a/openfga_sdk/api_client.py +++ b/openfga_sdk/api_client.py @@ -162,11 +162,17 @@ async def __call_api( _telemetry_attributes: dict[TelemetryAttribute, str | bool | int | float] | None = None, _streaming: bool = False, + _operation_name: str | None = None, ): self.configuration.is_valid() config = self.configuration start = float(time.time()) + if not _operation_name and _telemetry_attributes: + _operation_name = _telemetry_attributes.get( + TelemetryAttributes.fga_client_request_method + ) + # header parameters header_params = {**self.default_headers, **(header_params or {})} if self.cookie: @@ -316,6 +322,8 @@ async def __call_api( json.loads(e.body), response_type ) e.body = None + if _operation_name: + e.operation_name = _operation_name raise e except ApiException as e: e.body = e.body.decode("utf-8") @@ -326,6 +334,9 @@ async def __call_api( ) e.body = None + if _operation_name: + e.operation_name = _operation_name + _telemetry_attributes = TelemetryAttributes.fromResponse( response=e, credentials=self.configuration.credentials, @@ -548,6 +559,7 @@ async def call_api( _telemetry_attributes: dict[TelemetryAttribute, str | bool | int | float] | None = None, _streaming: bool = False, + _operation_name: str | None = None, ): """Makes the HTTP request (synchronous) and returns deserialized data. @@ -610,6 +622,7 @@ async def call_api( _oauth2_client, _telemetry_attributes, _streaming, + _operation_name, ) return self.pool.apply_async( @@ -634,6 +647,7 @@ async def call_api( _oauth2_client, _telemetry_attributes, _streaming, + _operation_name, ), ) diff --git a/openfga_sdk/exceptions.py b/openfga_sdk/exceptions.py index a10a554..f018dcd 100644 --- a/openfga_sdk/exceptions.py +++ b/openfga_sdk/exceptions.py @@ -116,7 +116,7 @@ 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() @@ -138,63 +138,127 @@ 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" + parts = [] + + if self.operation_name: + parts.append(f"Operation: {self.operation_name}") + + parts.append(f"Status: {self.status}") + + if self.code: + parts.append(f"Error Code: {self.code}") + + if self.error_message: + parts.append(f"Message: {self.error_message}") + elif self.reason: + parts.append(f"Reason: {self.reason}") + + if self.request_id: + parts.append(f"Request ID: {self.request_id}") + + if self.store_id: + parts.append(f"Store ID: {self.store_id}") + + if self.authorization_model_id: + parts.append(f"Authorization Model ID: {self.authorization_model_id}") if self.body: - error_message += f"HTTP response body: {self.body}\n" + parts.append(f"HTTP response body: {self.body}") - return error_message + return "\n".join(parts) @property def parsed_exception(self): - """ - Return the parsed body of the exception - """ return self._parsed_exception @parsed_exception.setter def parsed_exception(self, content): - """ - Update the deserialized content - """ self._parsed_exception = content + @property + def code(self): + if self._parsed_exception and hasattr(self._parsed_exception, "code"): + return self._parsed_exception.code + return None + + @property + def error_message(self): + if self._parsed_exception and hasattr(self._parsed_exception, "message"): + return self._parsed_exception.message + return None + + @property + def request_id(self): + return self.header.get(FGA_REQUEST_ID) + + @property + def store_id(self): + return self.header.get("store_id") + + @property + def authorization_model_id(self): + return self.header.get(OPENFGA_AUTHORIZATION_MODEL_ID) + + def is_validation_error(self): + return isinstance(self, ValidationException) or ( + self.code and "validation" in str(self.code).lower() + ) + + def is_not_found_error(self): + return isinstance(self, NotFoundException) or self.status == 404 + + def is_authentication_error(self): + return isinstance(self, (UnauthorizedException, AuthenticationError)) or self.status == 401 + + def is_authorization_error(self): + return isinstance(self, ForbiddenException) or self.status == 403 + + def is_rate_limit_error(self): + return isinstance(self, RateLimitExceededError) or self.status == 429 + + def is_server_error(self): + return isinstance(self, ServiceException) or (self.status and self.status >= 500) + + def is_retryable(self): + return self.status in [429, 500, 502, 503, 504] + 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) 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) 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) 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) 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) 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) 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) def render_path(path_to_item): diff --git a/openfga_sdk/sync/api_client.py b/openfga_sdk/sync/api_client.py index 1a07021..5f986bc 100644 --- a/openfga_sdk/sync/api_client.py +++ b/openfga_sdk/sync/api_client.py @@ -161,11 +161,17 @@ def __call_api( _telemetry_attributes: dict[TelemetryAttribute, str | bool | int | float] | None = None, _streaming: bool = False, + _operation_name: str | None = None, ): self.configuration.is_valid() config = self.configuration start = float(time.time()) + if not _operation_name and _telemetry_attributes: + _operation_name = _telemetry_attributes.get( + TelemetryAttributes.fga_client_request_method + ) + # header parameters header_params = {**self.default_headers, **(header_params or {})} if self.cookie: @@ -314,6 +320,8 @@ def __call_api( json.loads(e.body), response_type ) e.body = None + if _operation_name: + e.operation_name = _operation_name raise e except ApiException as e: e.body = e.body.decode("utf-8") @@ -324,6 +332,9 @@ def __call_api( ) e.body = None + if _operation_name: + e.operation_name = _operation_name + _telemetry_attributes = TelemetryAttributes.fromResponse( response=e, credentials=self.configuration.credentials, @@ -546,6 +557,7 @@ def call_api( _telemetry_attributes: dict[TelemetryAttribute, str | bool | int | float] | None = None, _streaming: bool = False, + _operation_name: str | None = None, ): """Makes the HTTP request (synchronous) and returns deserialized data. @@ -608,6 +620,7 @@ def call_api( _oauth2_client, _telemetry_attributes, _streaming, + _operation_name, ) return self.pool.apply_async( @@ -632,6 +645,7 @@ def call_api( _oauth2_client, _telemetry_attributes, _streaming, + _operation_name, ), ) diff --git a/run_integration_tests.sh b/run_integration_tests.sh new file mode 100755 index 0000000..d308112 --- /dev/null +++ b/run_integration_tests.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +set -e + +echo "=========================================" +echo "OpenFGA Python SDK Integration Tests" +echo "=========================================" +echo "" + +if ! command -v docker &> /dev/null; then + echo "Error: Docker is not installed or not in PATH" + exit 1 +fi + +echo "Step 1: Starting OpenFGA server..." +docker compose -f docker-compose.integration-test.yml up -d + +echo "Step 2: Waiting for server to be healthy..." +timeout=60 +elapsed=0 +while [ $elapsed -lt $timeout ]; do + if docker compose -f docker-compose.integration-test.yml ps | grep -q "healthy"; then + echo "Server is healthy!" + break + fi + sleep 2 + elapsed=$((elapsed + 2)) + echo "Waiting... ($elapsed/$timeout seconds)" +done + +if [ $elapsed -ge $timeout ]; then + echo "Error: Server did not become healthy in time" + docker compose -f docker-compose.integration-test.yml logs + docker compose -f docker-compose.integration-test.yml down + exit 1 +fi + +echo "" +echo "Step 3: Running integration tests..." +python -m pytest test/integration_error_handling_test.py -v -s || { + echo "" + echo "Tests failed. Cleaning up..." + docker compose -f docker-compose.integration-test.yml down + exit 1 +} + +echo "" +echo "Step 4: Cleaning up..." +docker compose -f docker-compose.integration-test.yml down + +echo "" +echo "=========================================" +echo "All integration tests passed!" +echo "=========================================" diff --git a/test/README_INTEGRATION_TESTS.md b/test/README_INTEGRATION_TESTS.md new file mode 100644 index 0000000..d9a5fc4 --- /dev/null +++ b/test/README_INTEGRATION_TESTS.md @@ -0,0 +1,114 @@ +# Integration Tests for Error Handling + +This directory contains integration tests that validate the improved error handling functionality in the OpenFGA Python SDK. + +## Prerequisites + +- Docker and Docker Compose +- Python 3.8+ +- OpenFGA Python SDK installed in development mode + +## Running the Tests + +### 1. Start OpenFGA Server + +Start an OpenFGA server using Docker: + +```bash +docker compose -f docker-compose.integration-test.yml up -d +``` + +Wait for the server to be ready (the healthcheck will ensure it's up): + +```bash +docker compose -f docker-compose.integration-test.yml ps +``` + +### 2. Run Integration Tests + +```bash +# From the repository root +python -m pytest test/integration_error_handling_test.py -v -s + +# Or using unittest +python -m unittest test.integration_error_handling_test -v +``` + +### 3. Stop OpenFGA Server + +```bash +docker compose -f docker-compose.integration-test.yml down +``` + +## What These Tests Demonstrate + +The integration tests showcase the following improvements to error handling: + +### 1. **Convenience Properties** +Instead of using header dictionaries: +```python +# OLD WAY +request_id = e.header.get('fga-request-id') +store_id = e.header.get('store_id') +model_id = e.header.get('openfga_authorization_model_id') +``` + +Now you can use direct properties: +```python +# NEW WAY +code = e.code +error_message = e.error_message +request_id = e.request_id +store_id = e.store_id +authorization_model_id = e.authorization_model_id +``` + +### 2. **Helper Methods** +Easily categorize errors: +```python +if e.is_validation_error(): + # Handle validation error +elif e.is_not_found_error(): + # Handle not found +elif e.is_retryable(): + # Retry the operation +``` + +### 3. **Enhanced Error Messages** +Errors now display comprehensive context: +``` +Operation: Check +Status: 400 +Error Code: validation_error +Message: Invalid user format +Request ID: abc-123-def-456 +Store ID: 01HXXX... +Authorization Model ID: 01GYYY... +``` + +### 4. **Operation Context** +Errors now include the operation name that failed, making debugging easier. + +## Test Scenarios + +The integration tests cover: + +1. **Validation Errors** - Invalid user format in check requests +2. **Not Found Errors** - Reading non-existent tuples +3. **Invalid Relations** - Writing tuples with undefined relations +4. **Invalid Model IDs** - Using non-existent authorization models +5. **Helper Methods** - Comprehensive testing of all error categorization methods +6. **Access Pattern Comparison** - Side-by-side comparison of old vs new patterns + +## Environment Variables + +- `OPENFGA_API_URL`: OpenFGA server URL (default: `http://localhost:8080`) + +## Troubleshooting + +If tests fail with connection errors: +1. Verify OpenFGA is running: `docker compose -f docker-compose.integration-test.yml ps` +2. Check server health: `curl http://localhost:8080/healthz` +3. View logs: `docker compose -f docker-compose.integration-test.yml logs` + +If you see "command not found" errors for docker commands, ensure Docker is installed and running. diff --git a/test/error_handling_improvements_test.py b/test/error_handling_improvements_test.py new file mode 100644 index 0000000..9dfc1af --- /dev/null +++ b/test/error_handling_improvements_test.py @@ -0,0 +1,188 @@ +import unittest +from openfga_sdk.exceptions import ( + ApiException, + ValidationException, + NotFoundException, + UnauthorizedException, + ForbiddenException, + ServiceException, + RateLimitExceededError, +) +from openfga_sdk.models import ValidationErrorMessageResponse, ErrorCode + + +class ErrorHandlingImprovementsTest(unittest.TestCase): + + def test_operation_name_parameter(self): + e = ApiException(status=400, reason="Bad Request", operation_name="Check") + self.assertEqual(e.operation_name, "Check") + + e2 = ValidationException(status=400, operation_name="Write") + self.assertEqual(e2.operation_name, "Write") + + def test_convenience_properties_with_parsed_exception(self): + e = ApiException(status=400, reason="Bad Request") + + error_response = ValidationErrorMessageResponse( + code="validation_error", + message="Invalid tuple format" + ) + e.parsed_exception = error_response + + self.assertEqual(e.code, "validation_error") + self.assertEqual(e.error_message, "Invalid tuple format") + + def test_convenience_properties_without_parsed_exception(self): + e = ApiException(status=500, reason="Internal Server Error") + + self.assertIsNone(e.code) + self.assertIsNone(e.error_message) + + def test_request_id_property(self): + e = ApiException(status=400, reason="Bad Request") + e.header["fga-request-id"] = "test-request-id-123" + + self.assertEqual(e.request_id, "test-request-id-123") + + def test_store_id_property(self): + e = ApiException(status=400, reason="Bad Request") + e.header["store_id"] = "store-123" + + self.assertEqual(e.store_id, "store-123") + + def test_authorization_model_id_property(self): + e = ApiException(status=400, reason="Bad Request") + e.header["openfga_authorization_model_id"] = "model-456" + + self.assertEqual(e.authorization_model_id, "model-456") + + def test_is_validation_error(self): + e1 = ValidationException(status=400) + self.assertTrue(e1.is_validation_error()) + + e2 = ApiException(status=400) + error_response = ValidationErrorMessageResponse( + code="validation_error", + message="test" + ) + e2.parsed_exception = error_response + self.assertTrue(e2.is_validation_error()) + + e3 = NotFoundException(status=404) + self.assertFalse(e3.is_validation_error()) + + def test_is_not_found_error(self): + e1 = NotFoundException(status=404) + self.assertTrue(e1.is_not_found_error()) + + e2 = ApiException(status=404) + self.assertTrue(e2.is_not_found_error()) + + e3 = ValidationException(status=400) + self.assertFalse(e3.is_not_found_error()) + + def test_is_authentication_error(self): + e1 = UnauthorizedException(status=401) + self.assertTrue(e1.is_authentication_error()) + + e2 = ApiException(status=401) + self.assertTrue(e2.is_authentication_error()) + + e3 = ValidationException(status=400) + self.assertFalse(e3.is_authentication_error()) + + def test_is_authorization_error(self): + e1 = ForbiddenException(status=403) + self.assertTrue(e1.is_authorization_error()) + + e2 = ApiException(status=403) + self.assertTrue(e2.is_authorization_error()) + + e3 = ValidationException(status=400) + self.assertFalse(e3.is_authorization_error()) + + def test_is_rate_limit_error(self): + e1 = RateLimitExceededError(status=429) + self.assertTrue(e1.is_rate_limit_error()) + + e2 = ApiException(status=429) + self.assertTrue(e2.is_rate_limit_error()) + + e3 = ValidationException(status=400) + self.assertFalse(e3.is_rate_limit_error()) + + def test_is_server_error(self): + e1 = ServiceException(status=500) + self.assertTrue(e1.is_server_error()) + + e2 = ApiException(status=502) + self.assertTrue(e2.is_server_error()) + + e3 = ApiException(status=503) + self.assertTrue(e3.is_server_error()) + + e4 = ValidationException(status=400) + self.assertFalse(e4.is_server_error()) + + def test_is_retryable(self): + retryable_codes = [429, 500, 502, 503, 504] + for code in retryable_codes: + e = ApiException(status=code) + self.assertTrue(e.is_retryable(), f"Status {code} should be retryable") + + non_retryable_codes = [400, 401, 403, 404] + for code in non_retryable_codes: + e = ApiException(status=code) + self.assertFalse(e.is_retryable(), f"Status {code} should not be retryable") + + def test_enhanced_str_with_operation_name(self): + e = ApiException(status=400, reason="Bad Request", operation_name="Check") + error_str = str(e) + + self.assertIn("Operation: Check", error_str) + self.assertIn("Status: 400", error_str) + + def test_enhanced_str_with_all_fields(self): + e = ApiException(status=400, reason="Bad Request", operation_name="Write") + + error_response = ValidationErrorMessageResponse( + code="validation_error", + message="Invalid tuple" + ) + e.parsed_exception = error_response + e.header["fga-request-id"] = "req-123" + e.header["store_id"] = "store-456" + e.header["openfga_authorization_model_id"] = "model-789" + + error_str = str(e) + + self.assertIn("Operation: Write", error_str) + self.assertIn("Status: 400", error_str) + self.assertIn("Error Code: validation_error", error_str) + self.assertIn("Message: Invalid tuple", error_str) + self.assertIn("Request ID: req-123", error_str) + self.assertIn("Store ID: store-456", error_str) + self.assertIn("Authorization Model ID: model-789", error_str) + + def test_enhanced_str_without_operation_name(self): + e = ApiException(status=400, reason="Bad Request") + error_str = str(e) + + self.assertNotIn("Operation:", error_str) + self.assertIn("Status: 400", error_str) + + def test_backwards_compatibility_with_parsed_exception(self): + e = ApiException(status=400, reason="Bad Request") + + error_response = ValidationErrorMessageResponse( + code="validation_error", + message="Test error" + ) + e.parsed_exception = error_response + + self.assertEqual(e.code, "validation_error") + self.assertEqual(e.error_message, "Test error") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/integration_error_handling_test.py b/test/integration_error_handling_test.py new file mode 100644 index 0000000..eb456b5 --- /dev/null +++ b/test/integration_error_handling_test.py @@ -0,0 +1,295 @@ +import unittest +import asyncio +import time +import os + +from openfga_sdk import OpenFgaClient +from openfga_sdk.client.models import ClientConfiguration +from openfga_sdk.exceptions import ( + ApiException, + ValidationException, + NotFoundException, + UnauthorizedException, +) +from openfga_sdk.models import ( + CheckRequest, + TupleKey, + WriteRequest, + WriteRequestWrites, + ReadRequest, +) + + +class IntegrationErrorHandlingTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + api_url = os.getenv("OPENFGA_API_URL", "http://localhost:8080") + cls.config = ClientConfiguration(api_url=api_url) + cls.client = OpenFgaClient(cls.config) + + cls.wait_for_server(api_url) + + async def create_store(): + response = await cls.client.create_store({"name": "Test Store"}) + return response.id + + cls.store_id = asyncio.run(create_store()) + cls.config.store_id = cls.store_id + + async def create_model(): + model = { + "type_definitions": [ + { + "type": "user", + }, + { + "type": "document", + "relations": { + "reader": { + "this": {}, + }, + "writer": { + "this": {}, + }, + }, + "metadata": { + "relations": { + "reader": {"directly_related_user_types": [{"type": "user"}]}, + "writer": {"directly_related_user_types": [{"type": "user"}]}, + } + }, + }, + ], + "schema_version": "1.1", + } + response = await cls.client.write_authorization_model(body=model) + return response.authorization_model_id + + cls.model_id = asyncio.run(create_model()) + + @classmethod + def wait_for_server(cls, api_url, max_retries=30, delay=1): + import requests + for i in range(max_retries): + try: + response = requests.get(f"{api_url}/healthz") + if response.status_code == 200: + return + except Exception: + pass + time.sleep(delay) + raise Exception(f"Server at {api_url} did not become ready in time") + + @classmethod + def tearDownClass(cls): + async def cleanup(): + await cls.client.close() + + asyncio.run(cleanup()) + + def test_validation_error_with_improved_properties(self): + async def run_test(): + try: + await self.client.check( + body=CheckRequest( + tuple_key=TupleKey( + user="invalid user format", + relation="reader", + object="document:budget", + ), + authorization_model_id=self.model_id, + ) + ) + self.fail("Expected ValidationException") + except ApiException as e: + self.assertIsNotNone(e.code, "Error should have code property") + self.assertIsNotNone(e.error_message, "Error should have error_message property") + self.assertIsNotNone(e.request_id, "Error should have request_id property") + self.assertIsNotNone(e.operation_name, "Error should have operation_name from telemetry") + self.assertEqual(e.operation_name, "check", "Operation name should be 'check'") + + self.assertTrue(e.is_validation_error(), "Should be identified as validation error") + self.assertFalse(e.is_not_found_error(), "Should not be identified as not found error") + self.assertFalse(e.is_server_error(), "Should not be identified as server error") + + error_str = str(e) + self.assertIn("Operation: check", error_str, "Error string should contain operation name") + self.assertIn("Status:", error_str, "Error string should contain status") + self.assertIn("Error Code:", error_str, "Error string should contain error code") + self.assertIn("Message:", error_str, "Error string should contain message") + self.assertIn("Request ID:", error_str, "Error string should contain request ID") + + print("\n=== Validation Error Example ===") + print(f"Direct property access:") + print(f" - Operation Name: {e.operation_name}") + print(f" - Error Code: {e.code}") + print(f" - Message: {e.error_message}") + print(f" - Request ID: {e.request_id}") + print(f" - Status: {e.status}") + print(f"\nHelper methods:") + print(f" - is_validation_error(): {e.is_validation_error()}") + print(f" - is_retryable(): {e.is_retryable()}") + print(f"\nFormatted error string:\n{error_str}") + + asyncio.run(run_test()) + + def test_not_found_error_with_improved_properties(self): + async def run_test(): + try: + await self.client.read( + body=ReadRequest( + tuple_key=TupleKey( + user="user:anne", + relation="reader", + object="document:nonexistent", + ) + ) + ) + except ApiException as e: + self.assertIsNotNone(e.request_id, "Error should have request_id property") + + self.assertFalse(e.is_validation_error(), "Should not be validation error") + self.assertFalse(e.is_server_error(), "Should not be server error") + + error_str = str(e) + self.assertIn("Status:", error_str) + self.assertIn("Request ID:", error_str) + + print("\n=== Not Found / Read Error Example ===") + print(f"Direct property access:") + print(f" - Request ID: {e.request_id}") + print(f" - Status: {e.status}") + print(f"\nFormatted error string:\n{error_str}") + + asyncio.run(run_test()) + + def test_invalid_tuple_validation_error(self): + async def run_test(): + try: + await self.client.write( + body=WriteRequest( + writes=WriteRequestWrites( + tuple_keys=[ + TupleKey( + user="user:anne", + relation="invalid_relation", + object="document:budget", + ) + ] + ) + ) + ) + self.fail("Expected ValidationException") + except ApiException as e: + self.assertIsNotNone(e.code) + self.assertIsNotNone(e.error_message) + self.assertIsNotNone(e.operation_name) + self.assertEqual(e.operation_name, "write", "Operation name should be 'write'") + self.assertTrue(e.is_validation_error()) + + print("\n=== Invalid Relation Validation Error ===") + print(f"Operation: {e.operation_name}") + print(f"Error Code: {e.code}") + print(f"Message: {e.error_message}") + print(f"Request ID: {e.request_id}") + print(f"\nFormatted:\n{str(e)}") + + asyncio.run(run_test()) + + def test_invalid_authorization_model_id(self): + async def run_test(): + try: + await self.client.check( + body=CheckRequest( + tuple_key=TupleKey( + user="user:anne", + relation="reader", + object="document:budget", + ), + authorization_model_id="01INVALID0MODEL0ID0000000000", + ) + ) + except ApiException as e: + self.assertIsNotNone(e.request_id) + + print("\n=== Invalid Model ID Error ===") + print(f"Request ID: {e.request_id}") + print(f"Status: {e.status}") + if e.code: + print(f"Error Code: {e.code}") + if e.error_message: + print(f"Message: {e.error_message}") + print(f"\nFormatted:\n{str(e)}") + + asyncio.run(run_test()) + + def test_helper_methods_comprehensive(self): + async def run_test(): + try: + await self.client.check( + body=CheckRequest( + tuple_key=TupleKey( + user="invalid format", + relation="reader", + object="document:budget", + ), + authorization_model_id=self.model_id, + ) + ) + except ApiException as e: + print("\n=== Helper Methods Showcase ===") + print(f"Exception type: {type(e).__name__}") + print(f"Status code: {e.status}") + print(f"\nHelper method results:") + print(f" - is_validation_error(): {e.is_validation_error()}") + print(f" - is_not_found_error(): {e.is_not_found_error()}") + print(f" - is_authentication_error(): {e.is_authentication_error()}") + print(f" - is_authorization_error(): {e.is_authorization_error()}") + print(f" - is_rate_limit_error(): {e.is_rate_limit_error()}") + print(f" - is_server_error(): {e.is_server_error()}") + print(f" - is_retryable(): {e.is_retryable()}") + + asyncio.run(run_test()) + + def test_convenience_vs_old_access_pattern(self): + async def run_test(): + try: + await self.client.check( + body=CheckRequest( + tuple_key=TupleKey( + user="bad format", + relation="reader", + object="document:budget", + ), + authorization_model_id=self.model_id, + ) + ) + except ApiException as e: + print("\n=== Old vs New Access Patterns ===") + print("\nOLD WAY (using header dict):") + print(f" request_id = e.header.get('fga-request-id')") + print(f" Result: {e.header.get('fga-request-id')}") + print(f" store_id = e.header.get('store_id')") + print(f" Result: {e.header.get('store_id')}") + + print("\nNEW WAY (convenient properties):") + print(f" code = e.code") + print(f" Result: {e.code}") + print(f" error_message = e.error_message") + print(f" Result: {e.error_message}") + print(f" request_id = e.request_id") + print(f" Result: {e.request_id}") + print(f" store_id = e.store_id") + print(f" Result: {e.store_id}") + + print("\nOLD ERROR STRING:") + old_style = f"({e.status})\nReason: {e.reason}\nHTTP response body: (would show raw JSON)" + + print("\nNEW ERROR STRING:") + print(str(e)) + + asyncio.run(run_test()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/test/operation_name_test.py b/test/operation_name_test.py new file mode 100644 index 0000000..52c0b31 --- /dev/null +++ b/test/operation_name_test.py @@ -0,0 +1,54 @@ +import unittest +from unittest.mock import Mock, patch +from openfga_sdk.exceptions import ApiException, ValidationException +from openfga_sdk.telemetry.attributes import TelemetryAttributes + + +class OperationNameExtractionTest(unittest.TestCase): + + def test_operation_name_extracted_from_telemetry(self): + telemetry_attrs = { + TelemetryAttributes.fga_client_request_method: "check", + TelemetryAttributes.fga_client_request_store_id: "store-123" + } + + operation_name = telemetry_attrs.get(TelemetryAttributes.fga_client_request_method) + + self.assertEqual(operation_name, "check") + + def test_various_operation_names(self): + operations = ["check", "write", "batch_check", "expand", "read", "list_objects"] + + for op in operations: + telemetry_attrs = { + TelemetryAttributes.fga_client_request_method: op + } + + operation_name = telemetry_attrs.get(TelemetryAttributes.fga_client_request_method) + self.assertEqual(operation_name, op) + + def test_operation_name_in_exception_message(self): + e = ValidationException(status=400, reason="Bad Request", operation_name="write") + error_str = str(e) + + self.assertIn("Operation: write", error_str) + + def test_operation_name_for_different_operations(self): + operations = [ + ("check", "Check operation"), + ("write", "Write operation"), + ("batch_check", "Batch check operation"), + ("expand", "Expand operation"), + ("read", "Read operation"), + ] + + for op_name, description in operations: + with self.subTest(operation=op_name): + e = ApiException(status=400, operation_name=op_name) + self.assertEqual(e.operation_name, op_name) + error_str = str(e) + self.assertIn(f"Operation: {op_name}", error_str) + + +if __name__ == "__main__": + unittest.main()