diff --git a/docs/pynest_exceptions_guide.md b/docs/pynest_exceptions_guide.md new file mode 100644 index 0000000..1e7de2d --- /dev/null +++ b/docs/pynest_exceptions_guide.md @@ -0,0 +1,145 @@ +# Pynest Exceptions Guide + +This guide provides a comprehensive overview of the exception handling system in Pynest, including built-in exceptions, custom exception creation, and best practices for integrating exceptions into your services and controllers. + +## Table of Contents + +1. [Introduction](#introduction) +2. [Available Exception Types](#available-exception-types) +3. [Creating Custom Exceptions](#creating-custom-exceptions) +4. [Using Exceptions in Services](#using-exceptions-in-services) +5. [Using Exceptions in Controllers](#using-exceptions-in-controllers) +6. [Best Practices](#best-practices) + +## Introduction + +Exception handling is a critical aspect of any robust and maintainable application. Pynest provides a set of built-in exceptions to handle common HTTP errors, as well as the ability to create custom exceptions tailored to your application's specific needs. By understanding and using these exceptions effectively, you can ensure consistent error handling and improve the user experience in your application. + +## Available Exception Types + +Pynest offers a range of built-in HTTP exceptions, all of which extend from the base `HttpException` class. These exceptions are designed to correspond to common HTTP status codes, allowing you to easily manage error responses in a standardized way. + +### Built-In HTTP Exceptions + +- **`BadRequestException` (400)**: Indicates that the server cannot process the request due to client-side input errors. +- **`UnauthorizedException` (401)**: Indicates that the client must authenticate itself to get the requested response. +- **`ForbiddenException` (403)**: Indicates that the client does not have permission to access the requested resource. +- **`NotFoundException` (404)**: Indicates that the server cannot find the requested resource. +- **`MethodNotAllowedException` (405)**: Indicates that the request method is not supported for the requested resource. +- **`NotAcceptableException` (406)**: Indicates that the requested resource is capable of generating only content not acceptable according to the Accept headers sent in the request. +- **`RequestTimeoutException` (408)**: Indicates that the server timed out waiting for the request. +- **`ConflictException` (409)**: Indicates that the request could not be processed because of conflict in the current state of the resource. +- **`GoneException` (410)**: Indicates that the requested resource is no longer available and will not be available again. +- **`PayloadTooLargeException` (413)**: Indicates that the request entity is larger than the server is willing or able to process. +- **`UnsupportedMediaTypeException` (415)**: Indicates that the media format of the requested data is not supported by the server. +- **`UnprocessableEntityException` (422)**: Indicates that the server understands the content type of the request entity, but was unable to process the contained instructions. +- **`TooManyRequestsException` (429)**: Indicates that the user has sent too many requests in a given amount of time ("rate limiting"). +- **`InternalServerErrorException` (500)**: Indicates that the server encountered an unexpected condition that prevented it from fulfilling the request. +- **`ServiceUnavailableException` (503)**: Indicates that the server is not ready to handle the request, typically due to temporary overloading or maintenance. + +## Creating Custom Exceptions + +In addition to the built-in exceptions, Pynest allows you to define custom exceptions to handle specific application-level errors. Custom exceptions should extend the `HttpException` class or a relevant subclass to ensure they integrate seamlessly with Pynest's error-handling system. + +### Example: Creating a Custom Exception + +```python +from pynest.exception import HttpException + +class CustomException(HttpException): + def __init__(self, message: str): + super().__init__(message, 418) # 418 is an example status code +``` + +In this example, `CustomException` is created with a specific message and an HTTP status code of 418. You can now throw this exception from your services or controllers. + +## Using Exceptions in Services + +Exceptions play a vital role in service logic, particularly when handling scenarios like validation errors, resource not found errors, or any business logic that needs to notify the client about an issue. + +### Example: Using Exceptions in a Service + +```python +from pynest.exception import BadRequestException, NotFoundException +from pynest.decorators import Injectable +from .user_model import User + +@Injectable +class UserService: + def __init__(self): + self.users: List[User] = [] # Mock database as a list + + def get_user(self, user_id: int) -> User: + user = next((user for user in self.users if user.id == user_id), None) + if not user: + raise NotFoundException(f"User with id {user_id} not found") + return user + + def create_user(self, user_data: dict) -> User: + if 'name' not in user_data: + raise BadRequestException("Name is required") + new_user = User(id=len(self.users) + 1, name=user_data['name']) + self.users.append(new_user) + return new_user +``` + +In this service example: +- `NotFoundException` is thrown if a user is not found in the list. +- `BadRequestException` is used to handle cases where required user data is missing. + +## Using Exceptions in Controllers + +Controllers are responsible for handling incoming requests and sending responses to the client. When exceptions occur in services, they can be caught and managed within controllers, ensuring that appropriate HTTP responses are sent back to the client. + +### Example: Using Exceptions in a Controller + +```python +from pynest.controller import Controller, Get, Post, Param, Body +from pynest.exception import BadRequestException, InternalServerErrorException +from .user_service import UserService + +@Controller('users') +class UserController: + def __init__(self, user_service: UserService): + self.user_service = user_service + + @Get(':id') + def get_user(self, @Param('id') user_id: int): + try: + return self.user_service.get_user(user_id) + except NotFoundException as e: + raise e # Re-throw to be handled globally or return specific response + except Exception as e: + raise InternalServerErrorException(f"An error occurred: {str(e)}") + + @Post() + def create_user(self, @Body() user_data: dict): + try: + return self.user_service.create_user(user_data) + except BadRequestException as e: + raise e # Specific exception for bad request + except Exception as e: + raise InternalServerErrorException(f"An error occurred: {str(e)}") +``` + +In this controller example: +- Specific exceptions like `NotFoundException` and `BadRequestException` are handled explicitly. +- General exceptions are wrapped in an `InternalServerErrorException` to ensure that unexpected errors do not expose sensitive information. + +## Best Practices + +1. **Use Specific Exceptions**: Use the most specific exception type that accurately reflects the error scenario. This helps in providing clear and precise feedback to the client. + +2. **Create Custom Exceptions for Domain-Specific Errors**: When your application logic requires error handling that isn't covered by the built-in exceptions, create custom exceptions to encapsulate these scenarios. + +3. **Avoid Exposing Sensitive Information**: Ensure that exception messages do not expose sensitive information, especially in production environments. Use generic messages or codes where necessary. + +4. **Log All Exceptions in Production**: Logging is crucial for debugging and monitoring. Ensure that all exceptions, particularly unhandled ones, are logged with sufficient detail to trace issues. + +5. **Validate Early**: Perform input validation as early as possible, using `BadRequestException` or custom validation exceptions. This prevents the application from processing invalid data. + +6. **Gracefully Handle Known Edge Cases**: Anticipate potential edge cases (like missing resources or invalid operations) and handle them gracefully using appropriate exceptions (`NotFoundException`, `ConflictException`, etc.). + +By following these best practices and fully utilizing Pynest's exception system, you can build applications that are not only robust and maintainable but also provide a seamless and user-friendly experience. + +--- \ No newline at end of file diff --git a/examples/BlankApp/src/user/user_controller.py b/examples/BlankApp/src/user/user_controller.py index b59d9f6..482eef3 100644 --- a/examples/BlankApp/src/user/user_controller.py +++ b/examples/BlankApp/src/user/user_controller.py @@ -1,4 +1,5 @@ from nest.core import Controller, Depends, Get, Post +from fastapi import HTTPException from .user_model import User from .user_service import UserService diff --git a/mkdocs.yml b/mkdocs.yml index f955e53..c9e4c53 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,6 +45,14 @@ extra_css: - styles/extra.css nav: + + - Introduction: introduction.md + - Getting Started: getting_started.md + - CLI Usage: cli.md + - Modules: modules.md + - Controllers: controllers.md + - Providers: providers.md + - Exception Handling: pynest_exceptions_guide.md - Overview: - Introduction: introduction.md - Getting Started: getting_started.md diff --git a/nest/common/exceptions.py b/nest/common/exceptions.py index 3d60fcc..65445c9 100644 --- a/nest/common/exceptions.py +++ b/nest/common/exceptions.py @@ -1,3 +1,5 @@ +from http import HTTPStatus +from typing import Any, Dict, Optional, Union class CircularDependencyException(Exception): def __init__(self, message="Circular dependency detected"): super().__init__(message) @@ -10,3 +12,390 @@ class UnknownModuleException(Exception): class NoneInjectableException(Exception): def __init__(self, message="None Injectable Classe Detected"): super().__init__(message) + + +class HttpException(Exception): + """ + Defines the base HTTP exception, which can be handled by a custom exception handler. + """ + + def __init__(self, response: Union[str, Dict[str, Any]], status: int, options: Optional[Dict[str, Any]] = None): + """ + Instantiate a plain HTTP Exception. + + :param response: string, object describing the error condition or the error cause. + :param status: HTTP response status code. + :param options: An object used to add an error cause. + """ + super().__init__() + self.response = response + self.status = status + self.options = options or {} + self.cause = self.options.get('cause') + self.message = self.init_message() + self.name = self.__class__.__name__ + + def init_message(self) -> str: + if isinstance(self.response, str): + return self.response + elif isinstance(self.response, dict) and 'message' in self.response and isinstance(self.response['message'], str): + return self.response['message'] + else: + return ' '.join([word for word in self.__class__.__name__.split('')]) or 'Error' + + def get_response(self) -> Union[str, Dict[str, Any]]: + return self.response + + def get_status(self) -> int: + return self.status + + @staticmethod + def create_body(message: Optional[Union[str, Dict[str, Any]]] = None, error: Optional[str] = None, status_code: Optional[int] = None) -> Dict[str, Any]: + if message is None: + return { + 'message': error, + 'status_code': status_code, + } + + if isinstance(message, (str, list)): + return { + 'message': message, + 'error': error, + 'status_code': status_code, + } + + return message + + @staticmethod + def get_description_from(description_or_options: Union[str, Dict[str, Any]]) -> str: + return description_or_options if isinstance(description_or_options, str) else description_or_options.get('description', '') + + @staticmethod + def get_http_exception_options_from(description_or_options: Union[str, Dict[str, Any]]) -> Dict[str, Any]: + return {} if isinstance(description_or_options, str) else description_or_options + + @staticmethod + def extract_description_and_options_from(description_or_options: Union[str, Dict[str, Any]]) -> Dict[str, Any]: + description = description_or_options if isinstance(description_or_options, str) else description_or_options.get('description', '') + http_exception_options = {} if isinstance(description_or_options, str) else description_or_options + return { + 'description': description, + 'http_exception_options': http_exception_options, + } + + + +class BadRequestException(HttpException): + """ + Exception for 400 Bad Request errors. + + This exception should be raised when the server cannot or will not process + the request due to an apparent client error. + + Attributes: + Inherits all attributes from HttpException. + """ + + def __init__(self, response: Union[str, Dict[str, Any]], options: Optional[Dict[str, Any]] = None): + """ + Initialize a new BadRequestException. + + Args: + response (Union[str, Dict[str, Any]]): The error message or response body. + options (Optional[Dict[str, Any]], optional): Additional options. Defaults to None. + """ + super().__init__(response, status=HTTPStatus.BAD_REQUEST, options=options) + +class UnauthorizedException(HttpException): + """ + Exception for 401 Unauthorized errors. + + This exception should be raised when authentication is required but has failed + or has not been provided. + + Attributes: + Inherits all attributes from HttpException. + """ + + def __init__(self, response: Union[str, Dict[str, Any]], options: Optional[Dict[str, Any]] = None): + """ + Initialize a new UnauthorizedException. + + Args: + response (Union[str, Dict[str, Any]]): The error message or response body. + options (Optional[Dict[str, Any]], optional): Additional options. Defaults to None. + """ + super().__init__(response, status=401, options=options) + +class ForbiddenException(HttpException): + """ + Exception for 403 Forbidden errors. + + This exception should be raised when the server understands the request + but refuses to authorize it. + + Attributes: + Inherits all attributes from HttpException. + """ + + def __init__(self, response: Union[str, Dict[str, Any]], options: Optional[Dict[str, Any]] = None): + """ + Initialize a new ForbiddenException. + + Args: + response (Union[str, Dict[str, Any]]): The error message or response body. + options (Optional[Dict[str, Any]], optional): Additional options. Defaults to None. + """ + super().__init__(response, status=HTTPStatus.FORBIDDEN, options=options) + +class NotFoundException(HttpException): + """ + Exception for 404 Not Found errors. + + This exception should be raised when the server cannot find the requested resource. + + Attributes: + Inherits all attributes from HttpException. + """ + + def __init__(self, response: Union[str, Dict[str, Any]], options: Optional[Dict[str, Any]] = None): + """ + Initialize a new NotFoundException. + + Args: + response (Union[str, Dict[str, Any]]): The error message or response body. + options (Optional[Dict[str, Any]], optional): Additional options. Defaults to None. + """ + super().__init__(response, status=HTTPStatus.NOT_FOUND, options=options) + +class MethodNotAllowedException(HttpException): + """ + Exception for 405 Method Not Allowed errors. + + This exception should be raised when the method specified in the request is + not allowed for the resource identified by the request URI. + + Attributes: + Inherits all attributes from HttpException. + """ + + def __init__(self, response: Union[str, Dict[str, Any]], options: Optional[Dict[str, Any]] = None): + """ + Initialize a new MethodNotAllowedException. + + Args: + response (Union[str, Dict[str, Any]]): The error message or response body. + options (Optional[Dict[str, Any]], optional): Additional options. Defaults to None. + """ + super().__init__(response, status=HTTPStatus.METHOD_NOT_ALLOWED, options=options) + +class NotAcceptableException(HttpException): + """ + Exception for 406 Not Acceptable errors. + + This exception should be raised when the server cannot produce a response + matching the list of acceptable values defined in the request's proactive + content negotiation headers. + + Attributes: + Inherits all attributes from HttpException. + """ + + def __init__(self, response: Union[str, Dict[str, Any]], options: Optional[Dict[str, Any]] = None): + """ + Initialize a new NotAcceptableException. + + Args: + response (Union[str, Dict[str, Any]]): The error message or response body. + options (Optional[Dict[str, Any]], optional): Additional options. Defaults to None. + """ + super().__init__(response, status=HTTPStatus.NOT_ACCEPTABLE, options=options) + +class RequestTimeoutException(HttpException): + """ + Exception for 408 Request Timeout errors. + + This exception should be raised when the server timed out waiting for the request. + + Attributes: + Inherits all attributes from HttpException. + """ + + def __init__(self, response: Union[str, Dict[str, Any]], options: Optional[Dict[str, Any]] = None): + """ + Initialize a new RequestTimeoutException. + + Args: + response (Union[str, Dict[str, Any]]): The error message or response body. + options (Optional[Dict[str, Any]], optional): Additional options. Defaults to None. + """ + super().__init__(response, status=HTTPStatus.REQUEST_TIMEOUT, options=options) + +class ConflictException(HttpException): + """ + Exception for 409 Conflict errors. + + This exception should be raised when a request conflicts with the current + state of the server. + + Attributes: + Inherits all attributes from HttpException. + """ + + def __init__(self, response: Union[str, Dict[str, Any]], options: Optional[Dict[str, Any]] = None): + """ + Initialize a new ConflictException. + + Args: + response (Union[str, Dict[str, Any]]): The error message or response body. + options (Optional[Dict[str, Any]], optional): Additional options. Defaults to None. + """ + super().__init__(response, status=HTTPStatus.CONFLICT, options=options) + +class GoneException(HttpException): + """ + Exception for 410 Gone errors. + + This exception should be raised when the requested resource is no longer + available and will not be available again. + + Attributes: + Inherits all attributes from HttpException. + """ + + def __init__(self, response: Union[str, Dict[str, Any]], options: Optional[Dict[str, Any]] = None): + """ + Initialize a new GoneException. + + Args: + response (Union[str, Dict[str, Any]]): The error message or response body. + options (Optional[Dict[str, Any]], optional): Additional options. Defaults to None. + """ + super().__init__(response, status=HTTPStatus.GONE, options=options) + +class PayloadTooLargeException(HttpException): + """ + Exception for 413 Payload Too Large errors. + + This exception should be raised when the request entity is larger than + limits defined by server. + + Attributes: + Inherits all attributes from HttpException. + """ + + def __init__(self, response: Union[str, Dict[str, Any]], options: Optional[Dict[str, Any]] = None): + """ + Initialize a new PayloadTooLargeException. + + Args: + response (Union[str, Dict[str, Any]]): The error message or response body. + options (Optional[Dict[str, Any]], optional): Additional options. Defaults to None. + """ + super().__init__(response, status=HTTPStatus.REQUEST_ENTITY_TOO_LARGE, options=options) + +class UnsupportedMediaTypeException(HttpException): + """ + Exception for 415 Unsupported Media Type errors. + + This exception should be raised when the media format of the requested data + is not supported by the server. + + Attributes: + Inherits all attributes from HttpException. + """ + + def __init__(self, response: Union[str, Dict[str, Any]], options: Optional[Dict[str, Any]] = None): + """ + Initialize a new UnsupportedMediaTypeException. + + Args: + response (Union[str, Dict[str, Any]]): The error message or response body. + options (Optional[Dict[str, Any]], optional): Additional options. Defaults to None. + """ + super().__init__(response, status=HTTPStatus.UNSUPPORTED_MEDIA_TYPE, options=options) + +class UnprocessableEntityException(HttpException): + """ + Exception for 422 Unprocessable Entity errors. + + This exception should be raised when the server understands the content type + of the request entity, and the syntax of the request entity is correct, but + it was unable to process the contained instructions. + + Attributes: + Inherits all attributes from HttpException. + """ + + def __init__(self, response: Union[str, Dict[str, Any]], options: Optional[Dict[str, Any]] = None): + """ + Initialize a new UnprocessableEntityException. + + Args: + response (Union[str, Dict[str, Any]]): The error message or response body. + options (Optional[Dict[str, Any]], optional): Additional options. Defaults to None. + """ + super().__init__(response, status=HTTPStatus.UNPROCESSABLE_ENTITY, options=options) + +class TooManyRequestsException(HttpException): + """ + Exception for 429 Too Many Requests errors. + + This exception should be raised when the user has sent too many requests + in a given amount of time ("rate limiting"). + + Attributes: + Inherits all attributes from HttpException. + """ + + def __init__(self, response: Union[str, Dict[str, Any]], options: Optional[Dict[str, Any]] = None): + """ + Initialize a new TooManyRequestsException. + + Args: + response (Union[str, Dict[str, Any]]): The error message or response body. + options (Optional[Dict[str, Any]], optional): Additional options. Defaults to None. + """ + super().__init__(response, status=HTTPStatus.TOO_MANY_REQUESTS, options=options) + +class InternalServerErrorException(HttpException): + """ + Exception for 500 Internal Server Error errors. + + This exception should be raised when the server encounters an unexpected + condition that prevents it from fulfilling the request. + + Attributes: + Inherits all attributes from HttpException. + """ + + def __init__(self, response: Union[str, Dict[str, Any]], options: Optional[Dict[str, Any]] = None): + """ + Initialize a new InternalServerErrorException. + + Args: + response (Union[str, Dict[str, Any]]): The error message or response body. + options (Optional[Dict[str, Any]], optional): Additional options. Defaults to None. + """ + super().__init__(response, status=HTTPStatus.INTERNAL_SERVER_ERROR, options=options) + +class ServiceUnavailableException(HttpException): + """ + Exception for 503 Service Unavailable errors. + + This exception should be raised when the server is not ready to handle the request. + Common causes are a server that is down for maintenance or that is overloaded. + + Attributes: + Inherits all attributes from HttpException. + """ + + def __init__(self, response: Union[str, Dict[str, Any]], options: Optional[Dict[str, Any]] = None): + """ + Initialize a new ServiceUnavailableException. + + Args: + response (Union[str, Dict[str, Any]]): The error message or response body. + options (Optional[Dict[str, Any]], optional): Additional options. Defaults to None. + """ + super().__init__(response, status=HTTPStatus.SERVICE_UNAVAILABLE, options=options) \ No newline at end of file diff --git a/nest/core/pynest_application.py b/nest/core/pynest_application.py index 22e2669..de71510 100644 --- a/nest/core/pynest_application.py +++ b/nest/core/pynest_application.py @@ -1,7 +1,8 @@ from typing import Any -from fastapi import FastAPI - +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from nest.common.exceptions import HttpException from nest.common.route_resolver import RoutesResolver from nest.core.pynest_app_context import PyNestApplicationContext from nest.core.pynest_container import PyNestContainer @@ -33,6 +34,7 @@ def __init__(self, container: PyNestContainer, http_server: FastAPI): self.routes_resolver = RoutesResolver(self.container, self.http_server) self.select_context_module() self.register_routes() + self._setup_except_hanlder() def use(self, middleware: type, **options: Any) -> "PyNestApp": """ @@ -62,3 +64,16 @@ def register_routes(self): Register the routes using the RoutesResolver. """ self.routes_resolver.register_routes() + + def _setup_except_hanlder(self): + @self.http_server.exception_handler(HttpException) + async def global_exception_handler(request: Request, exc:HttpException): + if isinstance(exc, HttpException): + return JSONResponse( + status_code=exc.status_code, + content=HttpException.create_body( + message=exc.message, + error=exc.options, + status_code=exc.status + ) + ) \ No newline at end of file diff --git a/tests/test_common/test_http_exception.py b/tests/test_common/test_http_exception.py new file mode 100644 index 0000000..efa5166 --- /dev/null +++ b/tests/test_common/test_http_exception.py @@ -0,0 +1,95 @@ +import pytest +from nest.common.exceptions import ( + CircularDependencyException, UnknownModuleException, NoneInjectableException, + HttpException, BadRequestException, UnauthorizedException, ForbiddenException, + NotFoundException, MethodNotAllowedException, NotAcceptableException, + RequestTimeoutException, ConflictException, GoneException, + PayloadTooLargeException, UnsupportedMediaTypeException, + UnprocessableEntityException, TooManyRequestsException, + InternalServerErrorException, ServiceUnavailableException +) + +@pytest.mark.parametrize("ExceptionClass, status_code", [ + (BadRequestException, 400), + (UnauthorizedException, 401), + (ForbiddenException, 403), + (NotFoundException, 404), + (MethodNotAllowedException, 405), + (NotAcceptableException, 406), + (RequestTimeoutException, 408), + (ConflictException, 409), + (GoneException, 410), + (PayloadTooLargeException, 413), + (UnsupportedMediaTypeException, 415), + (UnprocessableEntityException, 422), + (TooManyRequestsException, 429), + (InternalServerErrorException, 500), + (ServiceUnavailableException, 503), +]) +def test_http_exception_initialization(ExceptionClass, status_code): + message = f"Test {ExceptionClass.__name__}" + exception = ExceptionClass(message) + + assert exception.status == status_code + assert exception.message == message + assert exception.get_status() == status_code + assert exception.get_response() == message + +@pytest.mark.parametrize("ExceptionClass", [ + BadRequestException, UnauthorizedException, ForbiddenException, + NotFoundException, MethodNotAllowedException, NotAcceptableException, + RequestTimeoutException, ConflictException, GoneException, + PayloadTooLargeException, UnsupportedMediaTypeException, + UnprocessableEntityException, TooManyRequestsException, + InternalServerErrorException, ServiceUnavailableException +]) +def test_http_exception_with_options(ExceptionClass): + message = f"Test {ExceptionClass.__name__}" + options = {"cause": "Test cause"} + exception = ExceptionClass(message, options) + + assert exception.message == message + assert exception.cause == "Test cause" + + + +def test_http_exception_create_body(): + message = "Test message" + error = "TestError" + status_code = 400 + body = HttpException.create_body(message, error, status_code) + + assert body == { + "message": message, + "error": error, + "status_code": status_code + } + +def test_exceptions_inheritance(): + assert issubclass(CircularDependencyException, Exception) + assert issubclass(UnknownModuleException, Exception) + assert issubclass(NoneInjectableException, Exception) + assert all(issubclass(exc, HttpException) for exc in [ + BadRequestException, UnauthorizedException, ForbiddenException, + NotFoundException, MethodNotAllowedException, NotAcceptableException, + RequestTimeoutException, ConflictException, GoneException, + PayloadTooLargeException, UnsupportedMediaTypeException, + UnprocessableEntityException, TooManyRequestsException, + InternalServerErrorException, ServiceUnavailableException + ]) + assert issubclass(HttpException, Exception) + +@pytest.mark.parametrize("ExceptionClass", [ + BadRequestException, UnauthorizedException, ForbiddenException, + NotFoundException, MethodNotAllowedException, NotAcceptableException, + RequestTimeoutException, ConflictException, GoneException, + PayloadTooLargeException, UnsupportedMediaTypeException, + UnprocessableEntityException, TooManyRequestsException, + InternalServerErrorException, ServiceUnavailableException +]) +def test_exception_with_cause(ExceptionClass): + cause = ValueError("Original error") + exception = ExceptionClass("Error occurred", options={"cause": cause}) + + assert exception.cause == cause +