diff --git a/aws_lambda_powertools/event_handler/appsync.py b/aws_lambda_powertools/event_handler/appsync.py index 6f1cb72d067..3dbbf207859 100644 --- a/aws_lambda_powertools/event_handler/appsync.py +++ b/aws_lambda_powertools/event_handler/appsync.py @@ -53,6 +53,7 @@ def __init__(self): """ super().__init__() self.context = {} # early init as customers might add context before event resolution + self._exception_handlers: dict[type, Callable] = {} def __call__( self, @@ -142,12 +143,18 @@ def lambda_handler(event, context): self.lambda_context = context Router.lambda_context = context - if isinstance(event, list): - Router.current_batch_event = [data_model(e) for e in event] - response = self._call_batch_resolver(event=event, data_model=data_model) - else: - Router.current_event = data_model(event) - response = self._call_single_resolver(event=event, data_model=data_model) + try: + if isinstance(event, list): + Router.current_batch_event = [data_model(e) for e in event] + response = self._call_batch_resolver(event=event, data_model=data_model) + else: + Router.current_event = data_model(event) + response = self._call_single_resolver(event=event, data_model=data_model) + except Exception as exp: + response_builder = self._lookup_exception_handler(type(exp)) + if response_builder: + return response_builder(exp) + raise # We don't clear the context for coroutines because we don't have control over the event loop. # If we clean the context immediately, it might not be available when the coroutine is actually executed. @@ -470,3 +477,47 @@ def async_batch_resolver( raise_on_error=raise_on_error, aggregate=aggregate, ) + + def exception_handler(self, exc_class: type[Exception] | list[type[Exception]]): + """ + A decorator function that registers a handler for one or more exception types. + + Parameters + ---------- + exc_class (type[Exception] | list[type[Exception]]) + A single exception type or a list of exception types. + + Returns + ------- + Callable: + A decorator function that registers the exception handler. + """ + + def register_exception_handler(func: Callable): + if isinstance(exc_class, list): # pragma: no cover + for exp in exc_class: + self._exception_handlers[exp] = func + else: + self._exception_handlers[exc_class] = func + return func + + return register_exception_handler + + def _lookup_exception_handler(self, exp_type: type) -> Callable | None: + """ + Looks up the registered exception handler for the given exception type or its base classes. + + Parameters + ---------- + exp_type (type): + The exception type to look up the handler for. + + Returns + ------- + Callable | None: + The registered exception handler function if found, otherwise None. + """ + for cls in exp_type.__mro__: + if cls in self._exception_handlers: + return self._exception_handlers[cls] + return None diff --git a/aws_lambda_powertools/utilities/data_classes/code_pipeline_job_event.py b/aws_lambda_powertools/utilities/data_classes/code_pipeline_job_event.py index a52e5fbc7a2..3497227ed70 100644 --- a/aws_lambda_powertools/utilities/data_classes/code_pipeline_job_event.py +++ b/aws_lambda_powertools/utilities/data_classes/code_pipeline_job_event.py @@ -311,7 +311,7 @@ def put_artifact(self, artifact_name: str, body: Any, content_type: str) -> None key = artifact.location.s3_location.key # boto3 doesn't support None to omit the parameter when using ServerSideEncryption and SSEKMSKeyId - # So we are using if/else instead. + # So we are using if/else instead. if self.data.encryption_key: diff --git a/docs/core/event_handler/appsync.md b/docs/core/event_handler/appsync.md index a2f29e5dba5..0c556dedfbf 100644 --- a/docs/core/event_handler/appsync.md +++ b/docs/core/event_handler/appsync.md @@ -288,6 +288,19 @@ You can use `append_context` when you want to share data between your App and Ro --8<-- "examples/event_handler_graphql/src/split_operation_append_context_module.py" ``` +### Exception handling + +You can use **`exception_handler`** decorator with any Python exception. This allows you to handle a common exception outside your resolver, for example validation errors. + +The `exception_handler` function also supports passing a list of exception types you wish to handle with one handler. + +```python hl_lines="5-7 11" title="Exception handling" +--8<-- "examples/event_handler_graphql/src/exception_handling_graphql.py" +``` + +???+ warning + This is not supported when using async single resolvers. + ### Batch processing ```mermaid diff --git a/examples/event_handler_graphql/src/exception_handling_graphql.py b/examples/event_handler_graphql/src/exception_handling_graphql.py new file mode 100644 index 00000000000..b135f75112b --- /dev/null +++ b/examples/event_handler_graphql/src/exception_handling_graphql.py @@ -0,0 +1,17 @@ +from aws_lambda_powertools.event_handler import AppSyncResolver + +app = AppSyncResolver() + + +@app.exception_handler(ValueError) +def handle_value_error(ex: ValueError): + return {"message": "error"} + + +@app.resolver(field_name="createSomething") +def create_something(): + raise ValueError("Raising an exception") + + +def lambda_handler(event, context): + return app.resolve(event, context) diff --git a/tests/functional/event_handler/required_dependencies/appsync/test_appsync_batch_resolvers.py b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_batch_resolvers.py index c594be54a5b..59c5ec08a15 100644 --- a/tests/functional/event_handler/required_dependencies/appsync/test_appsync_batch_resolvers.py +++ b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_batch_resolvers.py @@ -981,3 +981,125 @@ async def get_user(event: List) -> List: # THEN the resolver must be able to return a field in the batch_current_event assert app.context == {} assert ret[0] == "powertools" + + +def test_exception_handler_with_batch_resolver_and_raise_exception(): + + # GIVEN a AppSyncResolver instance + app = AppSyncResolver() + + event = [ + { + "typeName": "Query", + "info": { + "fieldName": "listLocations", + "parentTypeName": "Post", + }, + "fieldName": "listLocations", + "arguments": {}, + "source": { + "id": "1", + }, + }, + { + "typeName": "Query", + "info": { + "fieldName": "listLocations", + "parentTypeName": "Post", + }, + "fieldName": "listLocations", + "arguments": {}, + "source": { + "id": "2", + }, + }, + { + "typeName": "Query", + "info": { + "fieldName": "listLocations", + "parentTypeName": "Post", + }, + "fieldName": "listLocations", + "arguments": {}, + "source": { + "id": [3, 4], + }, + }, + ] + + # WHEN we configure exception handler for ValueError + @app.exception_handler(ValueError) + def handle_value_error(ex: ValueError): + return {"message": "error"} + + # WHEN the sync batch resolver for the 'listLocations' field is defined with raise_on_error=True + @app.batch_resolver(field_name="listLocations", raise_on_error=True, aggregate=False) + def create_something(event: AppSyncResolverEvent) -> Optional[list]: # noqa AA03 VNE003 + raise ValueError + + # Call the implicit handler + result = app(event, {}) + + # THEN the return must be the Exception Handler error message + assert result["message"] == "error" + + +def test_exception_handler_with_batch_resolver_and_no_raise_exception(): + + # GIVEN a AppSyncResolver instance + app = AppSyncResolver() + + event = [ + { + "typeName": "Query", + "info": { + "fieldName": "listLocations", + "parentTypeName": "Post", + }, + "fieldName": "listLocations", + "arguments": {}, + "source": { + "id": "1", + }, + }, + { + "typeName": "Query", + "info": { + "fieldName": "listLocations", + "parentTypeName": "Post", + }, + "fieldName": "listLocations", + "arguments": {}, + "source": { + "id": "2", + }, + }, + { + "typeName": "Query", + "info": { + "fieldName": "listLocations", + "parentTypeName": "Post", + }, + "fieldName": "listLocations", + "arguments": {}, + "source": { + "id": [3, 4], + }, + }, + ] + + # WHEN we configure exception handler for ValueError + @app.exception_handler(ValueError) + def handle_value_error(ex: ValueError): + return {"message": "error"} + + # WHEN the sync batch resolver for the 'listLocations' field is defined with raise_on_error=False + @app.batch_resolver(field_name="listLocations", raise_on_error=False, aggregate=False) + def create_something(event: AppSyncResolverEvent) -> Optional[list]: # noqa AA03 VNE003 + raise ValueError + + # Call the implicit handler + result = app(event, {}) + + # THEN the return must not trigger the Exception Handler, but instead return from the resolver + assert result == [None, None, None] diff --git a/tests/functional/event_handler/required_dependencies/appsync/test_appsync_single_resolvers.py b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_single_resolvers.py index df44793f33b..d58c966e67b 100644 --- a/tests/functional/event_handler/required_dependencies/appsync/test_appsync_single_resolvers.py +++ b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_single_resolvers.py @@ -329,3 +329,25 @@ async def get_async(): # THEN assert asyncio.run(result) == "value" assert app.context == {} + + +def test_exception_handler_with_single_resolver(): + # GIVEN a AppSyncResolver instance + mock_event = load_event("appSyncDirectResolver.json") + + app = AppSyncResolver() + + # WHEN we configure exception handler for ValueError + @app.exception_handler(ValueError) + def handle_value_error(ex: ValueError): + return {"message": "error"} + + @app.resolver(field_name="createSomething") + def create_something(id: str): # noqa AA03 VNE003 + raise ValueError("Error") + + # Call the implicit handler + result = app(mock_event, {}) + + # THEN the return must be the Exception Handler error message + assert result["message"] == "error"