diff --git a/examples/exception_handling.py b/examples/exception_handling.py new file mode 100644 index 0000000..20d36c2 --- /dev/null +++ b/examples/exception_handling.py @@ -0,0 +1,215 @@ +"""Example demonstrating the use of new specific exception classes. + +This example shows how to handle different types of API errors using the +new W24AuthenticationError, W24ValidationError, W24RateLimitError, and +W24ServerError exception classes. +""" + +import asyncio + +from werk24 import ( + W24AuthenticationError, + W24RateLimitError, + W24ServerError, + W24ValidationError, + Werk24Client, +) + + +async def handle_api_errors(): + """Demonstrate error handling with specific exception types.""" + + # Example 1: Authentication Error (401) + # Note: In real usage, this would be raised by the API client + # For demonstration, we'll create the exception directly + print("Example 1: Authentication Error") + error = W24AuthenticationError( + details="Invalid token provided", + error_code="401", + error_details={"reason": "token_expired"}, + request_id="req-auth-123", + ) + print(f" Error code: {error.error_code}") + print(f" Details: {error.error_details}") + print(f" Request ID: {error.request_id}") + + # Example 2: Validation Error (400) + # This would be raised if invalid ask types are provided + error = W24ValidationError( + details="Invalid ask types specified", + error_details={ + "invalid_asks": ["INVALID_ASK"], + "valid_asks": ["VARIANT_MEASURES", "VARIANT_GDTS", "TITLE_BLOCK"], + }, + request_id="req-123", + ) + print(f"\nValidation Error: {error}") + + # Example 3: Rate Limit Error (429) + # This would be raised if rate limit is exceeded + error = W24RateLimitError( + details="Rate limit exceeded", + retry_after=60, + error_details={"limit": 1000, "current": 1000}, + request_id="req-456", + ) + print(f"\nRate Limit Error: {error}") + print(f"Retry after: {error.retry_after} seconds") + + # Example 4: Server Error (500) + error = W24ServerError( + details="Internal server error", error_code="500", request_id="req-789" + ) + print(f"\nServer Error: {error}") + print(f"Is transient: {error.is_transient}") + + # Example 5: Service Unavailable (503) + error = W24ServerError( + details="Service temporarily unavailable", + error_code="503", + error_details={"retry_after": 30}, + request_id="req-abc", + ) + print(f"\nService Unavailable: {error}") + print(f"Is transient: {error.is_transient}") + + +def parse_api_error_response(status_code: int, response_body: dict): + """Parse API error response and raise appropriate exception. + + This function demonstrates how to convert API error responses + (matching the ErrorResponse model from crew-api) into specific + exception types. + + Args: + status_code: HTTP status code from the API response + response_body: Parsed JSON response body with error details + + Raises: + W24AuthenticationError: For 401 responses + W24ValidationError: For 400 responses + W24RateLimitError: For 429 responses + W24ServerError: For 500/503 responses + """ + error_code = response_body.get("code", str(status_code)) + message = response_body.get("message", "An error occurred") + details = response_body.get("details", {}) + request_id = response_body.get("request_id") + + if status_code == 401: + raise W24AuthenticationError( + details=message, + error_code=error_code, + error_details=details, + request_id=request_id, + ) + elif status_code == 400: + raise W24ValidationError( + details=message, + error_code=error_code, + error_details=details, + request_id=request_id, + ) + elif status_code == 429: + retry_after = details.get("retry_after") + raise W24RateLimitError( + details=message, + error_code=error_code, + error_details=details, + request_id=request_id, + retry_after=retry_after, + ) + elif status_code in (500, 503): + raise W24ServerError( + details=message, + error_code=error_code, + error_details=details, + request_id=request_id, + is_transient=(status_code == 503), + ) + + +def example_error_handling_with_retry(): + """Example showing how to implement retry logic with rate limit errors.""" + import time + + max_retries = 3 + retry_count = 0 + + while retry_count < max_retries: + try: + # Simulate API call + # In real code, this would be: client.read_drawing(...) + + # Simulate rate limit error + if retry_count < 2: + raise W24RateLimitError( + details="Rate limit exceeded", + retry_after=2, # Short wait for demo + error_details={"limit": 100, "current": 100}, + ) + + print("Request succeeded!") + break + + except W24RateLimitError as e: + retry_count += 1 + if retry_count >= max_retries: + print(f"Max retries reached. Giving up.") + raise + + wait_time = e.retry_after or 5 + print( + f"Rate limited. Waiting {wait_time} seconds before retry {retry_count}/{max_retries}..." + ) + time.sleep(wait_time) + + except W24ServerError as e: + if e.is_transient: + retry_count += 1 + if retry_count >= max_retries: + print(f"Max retries reached. Giving up.") + raise + + wait_time = e.error_details.get("retry_after", 5) + print( + f"Service unavailable. Waiting {wait_time} seconds before retry {retry_count}/{max_retries}..." + ) + time.sleep(wait_time) + else: + # Non-transient error, don't retry + print(f"Permanent server error. Not retrying.") + raise + + +if __name__ == "__main__": + print("=== Exception Handling Examples ===\n") + + # Run async examples + asyncio.run(handle_api_errors()) + + print("\n=== Retry Logic Example ===\n") + try: + example_error_handling_with_retry() + except Exception as e: + print(f"Final error: {e}") + + print("\n=== Parsing API Error Response Example ===\n") + + # Example API error response (matches ErrorResponse model from crew-api) + api_response = { + "code": "400", + "message": "Invalid ask type specified", + "details": { + "invalid_asks": ["INVALID_ASK"], + "valid_asks": ["VARIANT_MEASURES", "VARIANT_GDTS", "TITLE_BLOCK"], + }, + "request_id": "123e4567-e89b-12d3-a456-426614174000", + } + + try: + parse_api_error_response(400, api_response) + except W24ValidationError as e: + print(f"Caught validation error: {e.error_code}") + print(f"Invalid asks: {e.error_details.get('invalid_asks')}") + print(f"Request ID: {e.request_id}") diff --git a/tests/test_new_exceptions.py b/tests/test_new_exceptions.py new file mode 100644 index 0000000..17a4bcd --- /dev/null +++ b/tests/test_new_exceptions.py @@ -0,0 +1,265 @@ +"""Tests for the new specific exception classes. + +This module tests the W24AuthenticationError, W24ValidationError, +W24RateLimitError, and W24ServerError exception classes to ensure +they properly handle error details and provide useful information. +""" + +import pytest + +from werk24.utils.exceptions import ( + W24AuthenticationError, + W24RateLimitError, + W24ServerError, + W24ValidationError, +) + + +class TestW24AuthenticationError: + """Tests for W24AuthenticationError exception class.""" + + def test_basic_initialization(self): + """Test basic initialization with default values.""" + error = W24AuthenticationError() + assert error.error_code == "401" + assert error.error_details == {} + assert error.request_id is None + assert "Authentication with the Werk24 API failed" in error.cli_message_body + + def test_initialization_with_details(self): + """Test initialization with custom details.""" + error = W24AuthenticationError( + details="Invalid token provided", + error_code="401", + error_details={"reason": "token_expired"}, + request_id="test-request-123", + ) + assert error.error_code == "401" + assert error.error_details == {"reason": "token_expired"} + assert error.request_id == "test-request-123" + assert "Invalid token provided" in str(error) + + def test_inheritance(self): + """Test that W24AuthenticationError inherits from TechreadException.""" + error = W24AuthenticationError() + from werk24.utils.exceptions import TechreadException + + assert isinstance(error, TechreadException) + + +class TestW24ValidationError: + """Tests for W24ValidationError exception class.""" + + def test_basic_initialization(self): + """Test basic initialization with default values.""" + error = W24ValidationError() + assert error.error_code == "400" + assert error.error_details == {} + assert error.request_id is None + assert "The request failed validation" in error.cli_message_body + + def test_initialization_with_invalid_asks(self): + """Test initialization with invalid ask types.""" + error = W24ValidationError( + details="Invalid ask types specified", + error_details={ + "invalid_asks": ["INVALID_ASK_1", "INVALID_ASK_2"], + "valid_asks": ["VARIANT_MEASURES", "VARIANT_GDTS", "TITLE_BLOCK"], + }, + request_id="test-request-456", + ) + assert error.error_code == "400" + assert "INVALID_ASK_1" in str(error) + assert "VARIANT_MEASURES" in str(error) + assert error.request_id == "test-request-456" + + def test_initialization_with_field_error(self): + """Test initialization with field-specific error.""" + error = W24ValidationError( + details="Field validation failed", + error_details={"field": "asks", "error": "must not be empty"}, + ) + assert "Field 'asks'" in str(error) + assert "must not be empty" in str(error) + + def test_initialization_with_many_valid_asks(self): + """Test that long lists of valid asks are truncated.""" + valid_asks = [f"ASK_{i}" for i in range(20)] + error = W24ValidationError( + error_details={"invalid_asks": ["BAD_ASK"], "valid_asks": valid_asks} + ) + # Should show first 5 and ellipsis + assert "..." in str(error) + + def test_inheritance(self): + """Test that W24ValidationError inherits from TechreadException.""" + error = W24ValidationError() + from werk24.utils.exceptions import TechreadException + + assert isinstance(error, TechreadException) + + +class TestW24RateLimitError: + """Tests for W24RateLimitError exception class.""" + + def test_basic_initialization(self): + """Test basic initialization with default values.""" + error = W24RateLimitError() + assert error.error_code == "429" + assert error.error_details == {} + assert error.request_id is None + assert error.retry_after is None + assert "You have exceeded the API rate limit" in error.cli_message_body + + def test_initialization_with_retry_after(self): + """Test initialization with retry_after parameter.""" + error = W24RateLimitError( + details="Rate limit exceeded", + retry_after=60, + error_details={"limit": 1000, "current": 1000}, + request_id="test-request-789", + ) + assert error.retry_after == 60 + assert "60 seconds" in str(error) + assert "1000/1000" in str(error) + assert error.request_id == "test-request-789" + + def test_initialization_with_retry_after_in_details(self): + """Test that retry_after is extracted from error_details.""" + error = W24RateLimitError( + error_details={"retry_after": 120, "limit": 500, "current": 500} + ) + assert error.retry_after == 120 + assert "120 seconds" in str(error) + + def test_retry_after_parameter_takes_precedence(self): + """Test that explicit retry_after parameter takes precedence.""" + error = W24RateLimitError(retry_after=30, error_details={"retry_after": 60}) + assert error.retry_after == 30 + + def test_inheritance(self): + """Test that W24RateLimitError inherits from TechreadException.""" + error = W24RateLimitError() + from werk24.utils.exceptions import TechreadException + + assert isinstance(error, TechreadException) + + +class TestW24ServerError: + """Tests for W24ServerError exception class.""" + + def test_basic_initialization(self): + """Test basic initialization with default values.""" + error = W24ServerError() + assert error.error_code == "500" + assert error.error_details == {} + assert error.request_id is None + assert error.is_transient is False + assert "The Werk24 API encountered an error" in error.cli_message_body + + def test_initialization_with_500_error(self): + """Test initialization with 500 Internal Server Error.""" + error = W24ServerError( + details="Internal server error occurred", + error_code="500", + error_details={"error_type": "database_connection"}, + request_id="test-request-abc", + ) + assert error.error_code == "500" + assert error.is_transient is False + assert "test-request-abc" in str(error) + + def test_initialization_with_503_error(self): + """Test initialization with 503 Service Unavailable.""" + error = W24ServerError( + details="Service temporarily unavailable", + error_code="503", + error_details={"retry_after": 30}, + request_id="test-request-def", + ) + assert error.error_code == "503" + assert error.is_transient is True + assert "temporarily unavailable" in str(error) + assert "30 seconds" in str(error) + + def test_is_transient_flag(self): + """Test that is_transient flag can be set explicitly.""" + error = W24ServerError(error_code="500", is_transient=True) + assert error.is_transient is True + + def test_request_id_in_message(self): + """Test that request_id is included in the error message.""" + error = W24ServerError(request_id="unique-request-id-123") + assert "unique-request-id-123" in str(error) + + def test_inheritance(self): + """Test that W24ServerError inherits from TechreadException.""" + error = W24ServerError() + from werk24.utils.exceptions import TechreadException + + assert isinstance(error, TechreadException) + + +class TestExceptionAttributes: + """Tests for common attributes across all new exception classes.""" + + def test_all_exceptions_have_error_code(self): + """Test that all new exceptions have error_code attribute.""" + exceptions = [ + W24AuthenticationError(), + W24ValidationError(), + W24RateLimitError(), + W24ServerError(), + ] + for exc in exceptions: + assert hasattr(exc, "error_code") + assert isinstance(exc.error_code, str) + + def test_all_exceptions_have_error_details(self): + """Test that all new exceptions have error_details attribute.""" + exceptions = [ + W24AuthenticationError(), + W24ValidationError(), + W24RateLimitError(), + W24ServerError(), + ] + for exc in exceptions: + assert hasattr(exc, "error_details") + assert isinstance(exc.error_details, dict) + + def test_all_exceptions_have_request_id(self): + """Test that all new exceptions have request_id attribute.""" + exceptions = [ + W24AuthenticationError(), + W24ValidationError(), + W24RateLimitError(), + W24ServerError(), + ] + for exc in exceptions: + assert hasattr(exc, "request_id") + + def test_all_exceptions_have_cli_message_header(self): + """Test that all new exceptions have cli_message_header attribute.""" + exceptions = [ + W24AuthenticationError(), + W24ValidationError(), + W24RateLimitError(), + W24ServerError(), + ] + for exc in exceptions: + assert hasattr(exc, "cli_message_header") + assert isinstance(exc.cli_message_header, str) + assert len(exc.cli_message_header) > 0 + + def test_all_exceptions_have_cli_message_body(self): + """Test that all new exceptions have cli_message_body attribute.""" + exceptions = [ + W24AuthenticationError(), + W24ValidationError(), + W24RateLimitError(), + W24ServerError(), + ] + for exc in exceptions: + assert hasattr(exc, "cli_message_body") + assert isinstance(exc.cli_message_body, str) + assert len(exc.cli_message_body) > 0 diff --git a/werk24/utils/__init__.py b/werk24/utils/__init__.py index e6b8c18..39a0aa3 100644 --- a/werk24/utils/__init__.py +++ b/werk24/utils/__init__.py @@ -1,3 +1,19 @@ from .assets import get_test_drawing as get_test_drawing from .assets import read_drawing_sync as read_drawing_sync from .assets import read_example_drawing as read_example_drawing +from .exceptions import BadRequestException as BadRequestException +from .exceptions import EncryptionException as EncryptionException +from .exceptions import InsufficientCreditsException as InsufficientCreditsException +from .exceptions import InvalidLicenseException as InvalidLicenseException +from .exceptions import RequestTooLargeException as RequestTooLargeException +from .exceptions import ResourceNotFoundException as ResourceNotFoundException +from .exceptions import ServerException as ServerException +from .exceptions import SSLCertificateError as SSLCertificateError +from .exceptions import TechreadException as TechreadException +from .exceptions import UnauthorizedException as UnauthorizedException +from .exceptions import UnsupportedMediaType as UnsupportedMediaType +from .exceptions import UserInputError as UserInputError +from .exceptions import W24AuthenticationError as W24AuthenticationError +from .exceptions import W24RateLimitError as W24RateLimitError +from .exceptions import W24ServerError as W24ServerError +from .exceptions import W24ValidationError as W24ValidationError diff --git a/werk24/utils/exceptions.py b/werk24/utils/exceptions.py index c70edbe..0b2b194 100644 --- a/werk24/utils/exceptions.py +++ b/werk24/utils/exceptions.py @@ -52,7 +52,7 @@ class RequestTooLargeException(TechreadException): cli_message_header: str = "Request Too Large" cli_message_body: str = ( "The request size exceeds the maximum allowed size of 10MB.\n\n" - "For more information, visit:\nhttps://docs.werk24.io/limitations/drawing_file_size.html" + "For more information, visit:\nhttps://v2.docs.werk24.io" ) @@ -62,7 +62,7 @@ class UnsupportedMediaType(TechreadException): cli_message_header: str = "Unsupported Media Type" cli_message_body: str = ( "The uploaded file format is not supported.\n\n" - "For a list of supported formats, visit:\nhttps://docs.werk24.io/limitations/drawing_file_format.html" + "For a list of supported formats, visit:\nhttps://v2.docs.werk24.io" ) @@ -131,3 +131,244 @@ class InvalidLicenseException(TechreadException): "The provided license is invalid or has expired.\n\n" "Please ensure that you provide a token AND a region." ) + + +class W24AuthenticationError(TechreadException): + """Exception raised when authentication fails (401 responses). + + This exception is raised when the API returns a 401 status code, + indicating that the authentication credentials are invalid, expired, + or missing. + + Attributes: + error_code: The specific error code from the API response + error_details: Additional details about the authentication failure + request_id: Unique identifier for the failed request + """ + + cli_message_header: str = "Authentication Failed" + cli_message_body: str = ( + "Authentication with the Werk24 API failed.\n\n" + "Please verify that:\n" + "1. Your API token is valid and has not expired\n" + "2. Your token has the necessary permissions\n" + "3. You are using the correct region\n\n" + "For assistance, contact support@werk24.io" + ) + + def __init__( + self, + details: str = "", + error_code: str = "401", + error_details: dict = None, + request_id: str = None, + ): + """Initialize the authentication error with structured error information. + + Args: + details: Human-readable error message + error_code: HTTP status code or application-specific error code + error_details: Additional context about the error + request_id: Unique identifier for the request + """ + self.error_code = error_code + self.error_details = error_details or {} + self.request_id = request_id + super().__init__(details) + + +class W24ValidationError(TechreadException): + """Exception raised when request validation fails (400 responses). + + This exception is raised when the API returns a 400 status code, + indicating that the request contains invalid data, malformed input, + or violates validation rules. + + Attributes: + error_code: The specific error code from the API response + error_details: Detailed validation errors (e.g., invalid fields, invalid ask types) + request_id: Unique identifier for the failed request + """ + + cli_message_header: str = "Validation Error" + cli_message_body: str = ( + "The request failed validation.\n\n" + "Please check your request parameters and ensure:\n" + "1. All required fields are provided\n" + "2. Field values are in the correct format\n" + "3. Ask types are valid and supported\n" + "4. File format is supported (PDF, PNG, JPEG, TIFF)\n\n" + "For more information, visit: https://v2.docs.werk24.io" + ) + + def __init__( + self, + details: str = "", + error_code: str = "400", + error_details: dict = None, + request_id: str = None, + ): + """Initialize the validation error with structured error information. + + Args: + details: Human-readable error message + error_code: HTTP status code or application-specific error code + error_details: Detailed validation errors (e.g., invalid_asks, valid_asks) + request_id: Unique identifier for the request + """ + self.error_code = error_code + self.error_details = error_details or {} + self.request_id = request_id + + # Enhance message with validation details if available + if error_details: + detail_lines = [] + if "invalid_asks" in error_details: + detail_lines.append( + f"Invalid ask types: {', '.join(error_details['invalid_asks'])}" + ) + if "valid_asks" in error_details: + detail_lines.append( + f"Valid ask types: {', '.join(error_details['valid_asks'][:5])}..." + if len(error_details["valid_asks"]) > 5 + else f"Valid ask types: {', '.join(error_details['valid_asks'])}" + ) + if "field" in error_details: + detail_lines.append( + f"Field '{error_details['field']}': {error_details.get('error', 'invalid')}" + ) + if detail_lines: + details = ( + f"{details}\n\n" + "\n".join(detail_lines) + if details + else "\n".join(detail_lines) + ) + + super().__init__(details) + + +class W24RateLimitError(TechreadException): + """Exception raised when rate limit is exceeded (429 responses). + + This exception is raised when the API returns a 429 status code, + indicating that the client has sent too many requests in a given + time period. + + Attributes: + error_code: The specific error code from the API response + error_details: Rate limit information (e.g., retry_after, limit, current) + request_id: Unique identifier for the failed request + retry_after: Number of seconds to wait before retrying + """ + + cli_message_header: str = "Rate Limit Exceeded" + cli_message_body: str = ( + "You have exceeded the API rate limit.\n\n" + "Please wait before sending additional requests.\n" + "Consider implementing exponential backoff in your application.\n\n" + "For information about rate limits, visit: https://v2.docs.werk24.io" + ) + + def __init__( + self, + details: str = "", + error_code: str = "429", + error_details: dict = None, + request_id: str = None, + retry_after: int = None, + ): + """Initialize the rate limit error with structured error information. + + Args: + details: Human-readable error message + error_code: HTTP status code or application-specific error code + error_details: Rate limit details (retry_after, limit, current) + request_id: Unique identifier for the request + retry_after: Number of seconds to wait before retrying + """ + self.error_code = error_code + self.error_details = error_details or {} + self.request_id = request_id + self.retry_after = ( + retry_after or error_details.get("retry_after") if error_details else None + ) + + # Enhance message with retry information + if self.retry_after: + details = ( + f"{details}\n\nPlease retry after {self.retry_after} seconds." + if details + else f"Please retry after {self.retry_after} seconds." + ) + + if error_details and "limit" in error_details: + limit_info = f"Rate limit: {error_details.get('current', '?')}/{error_details['limit']} requests" + details = f"{details}\n{limit_info}" if details else limit_info + + super().__init__(details) + + +class W24ServerError(TechreadException): + """Exception raised for server errors (500/503 responses). + + This exception is raised when the API returns a 500 (Internal Server Error) + or 503 (Service Unavailable) status code, indicating a problem on the + server side. + + Attributes: + error_code: The specific error code from the API response + error_details: Additional context about the server error + request_id: Unique identifier for the failed request + is_transient: Whether the error is likely temporary (503) or persistent (500) + """ + + cli_message_header: str = "Server Error" + cli_message_body: str = ( + "The Werk24 API encountered an error while processing your request.\n\n" + "The service team has been notified and will investigate the issue.\n" + "Please try again later.\n\n" + "If the problem persists, contact support@werk24.io with your request ID." + ) + + def __init__( + self, + details: str = "", + error_code: str = "500", + error_details: dict = None, + request_id: str = None, + is_transient: bool = False, + ): + """Initialize the server error with structured error information. + + Args: + details: Human-readable error message + error_code: HTTP status code (500 or 503) + error_details: Additional context about the error + request_id: Unique identifier for the request + is_transient: True for 503 (temporary), False for 500 (persistent) + """ + self.error_code = error_code + self.error_details = error_details or {} + self.request_id = request_id + self.is_transient = is_transient or error_code == "503" + + # Enhance message based on error type + if self.is_transient: + self.cli_message_header = "Service Temporarily Unavailable" + retry_msg = ( + "The service is temporarily unavailable. Please retry your request." + ) + if error_details and "retry_after" in error_details: + retry_msg += ( + f" Estimated wait time: {error_details['retry_after']} seconds." + ) + details = f"{details}\n\n{retry_msg}" if details else retry_msg + + if request_id: + details = ( + f"{details}\n\nRequest ID: {request_id}" + if details + else f"Request ID: {request_id}" + ) + + super().__init__(details)