Skip to content

Commit db8a338

Browse files
feat(event_handler): add exception handling mechanism for AppSyncResolver (#5588)
* Adding exception handler support * Adding exception handler support - fix tests * Adding exception handler support - fix tests + docs
1 parent 3ff5132 commit db8a338

File tree

6 files changed

+232
-7
lines changed

6 files changed

+232
-7
lines changed

aws_lambda_powertools/event_handler/appsync.py

+57-6
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def __init__(self):
5353
"""
5454
super().__init__()
5555
self.context = {} # early init as customers might add context before event resolution
56+
self._exception_handlers: dict[type, Callable] = {}
5657

5758
def __call__(
5859
self,
@@ -142,12 +143,18 @@ def lambda_handler(event, context):
142143
self.lambda_context = context
143144
Router.lambda_context = context
144145

145-
if isinstance(event, list):
146-
Router.current_batch_event = [data_model(e) for e in event]
147-
response = self._call_batch_resolver(event=event, data_model=data_model)
148-
else:
149-
Router.current_event = data_model(event)
150-
response = self._call_single_resolver(event=event, data_model=data_model)
146+
try:
147+
if isinstance(event, list):
148+
Router.current_batch_event = [data_model(e) for e in event]
149+
response = self._call_batch_resolver(event=event, data_model=data_model)
150+
else:
151+
Router.current_event = data_model(event)
152+
response = self._call_single_resolver(event=event, data_model=data_model)
153+
except Exception as exp:
154+
response_builder = self._lookup_exception_handler(type(exp))
155+
if response_builder:
156+
return response_builder(exp)
157+
raise
151158

152159
# We don't clear the context for coroutines because we don't have control over the event loop.
153160
# 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(
470477
raise_on_error=raise_on_error,
471478
aggregate=aggregate,
472479
)
480+
481+
def exception_handler(self, exc_class: type[Exception] | list[type[Exception]]):
482+
"""
483+
A decorator function that registers a handler for one or more exception types.
484+
485+
Parameters
486+
----------
487+
exc_class (type[Exception] | list[type[Exception]])
488+
A single exception type or a list of exception types.
489+
490+
Returns
491+
-------
492+
Callable:
493+
A decorator function that registers the exception handler.
494+
"""
495+
496+
def register_exception_handler(func: Callable):
497+
if isinstance(exc_class, list): # pragma: no cover
498+
for exp in exc_class:
499+
self._exception_handlers[exp] = func
500+
else:
501+
self._exception_handlers[exc_class] = func
502+
return func
503+
504+
return register_exception_handler
505+
506+
def _lookup_exception_handler(self, exp_type: type) -> Callable | None:
507+
"""
508+
Looks up the registered exception handler for the given exception type or its base classes.
509+
510+
Parameters
511+
----------
512+
exp_type (type):
513+
The exception type to look up the handler for.
514+
515+
Returns
516+
-------
517+
Callable | None:
518+
The registered exception handler function if found, otherwise None.
519+
"""
520+
for cls in exp_type.__mro__:
521+
if cls in self._exception_handlers:
522+
return self._exception_handlers[cls]
523+
return None

aws_lambda_powertools/utilities/data_classes/code_pipeline_job_event.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ def put_artifact(self, artifact_name: str, body: Any, content_type: str) -> None
311311
key = artifact.location.s3_location.key
312312

313313
# boto3 doesn't support None to omit the parameter when using ServerSideEncryption and SSEKMSKeyId
314-
# So we are using if/else instead.
314+
# So we are using if/else instead.
315315

316316
if self.data.encryption_key:
317317

docs/core/event_handler/appsync.md

+13
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,19 @@ You can use `append_context` when you want to share data between your App and Ro
288288
--8<-- "examples/event_handler_graphql/src/split_operation_append_context_module.py"
289289
```
290290

291+
### Exception handling
292+
293+
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.
294+
295+
The `exception_handler` function also supports passing a list of exception types you wish to handle with one handler.
296+
297+
```python hl_lines="5-7 11" title="Exception handling"
298+
--8<-- "examples/event_handler_graphql/src/exception_handling_graphql.py"
299+
```
300+
301+
???+ warning
302+
This is not supported when using async single resolvers.
303+
291304
### Batch processing
292305

293306
```mermaid
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from aws_lambda_powertools.event_handler import AppSyncResolver
2+
3+
app = AppSyncResolver()
4+
5+
6+
@app.exception_handler(ValueError)
7+
def handle_value_error(ex: ValueError):
8+
return {"message": "error"}
9+
10+
11+
@app.resolver(field_name="createSomething")
12+
def create_something():
13+
raise ValueError("Raising an exception")
14+
15+
16+
def lambda_handler(event, context):
17+
return app.resolve(event, context)

tests/functional/event_handler/required_dependencies/appsync/test_appsync_batch_resolvers.py

+122
Original file line numberDiff line numberDiff line change
@@ -981,3 +981,125 @@ async def get_user(event: List) -> List:
981981
# THEN the resolver must be able to return a field in the batch_current_event
982982
assert app.context == {}
983983
assert ret[0] == "powertools"
984+
985+
986+
def test_exception_handler_with_batch_resolver_and_raise_exception():
987+
988+
# GIVEN a AppSyncResolver instance
989+
app = AppSyncResolver()
990+
991+
event = [
992+
{
993+
"typeName": "Query",
994+
"info": {
995+
"fieldName": "listLocations",
996+
"parentTypeName": "Post",
997+
},
998+
"fieldName": "listLocations",
999+
"arguments": {},
1000+
"source": {
1001+
"id": "1",
1002+
},
1003+
},
1004+
{
1005+
"typeName": "Query",
1006+
"info": {
1007+
"fieldName": "listLocations",
1008+
"parentTypeName": "Post",
1009+
},
1010+
"fieldName": "listLocations",
1011+
"arguments": {},
1012+
"source": {
1013+
"id": "2",
1014+
},
1015+
},
1016+
{
1017+
"typeName": "Query",
1018+
"info": {
1019+
"fieldName": "listLocations",
1020+
"parentTypeName": "Post",
1021+
},
1022+
"fieldName": "listLocations",
1023+
"arguments": {},
1024+
"source": {
1025+
"id": [3, 4],
1026+
},
1027+
},
1028+
]
1029+
1030+
# WHEN we configure exception handler for ValueError
1031+
@app.exception_handler(ValueError)
1032+
def handle_value_error(ex: ValueError):
1033+
return {"message": "error"}
1034+
1035+
# WHEN the sync batch resolver for the 'listLocations' field is defined with raise_on_error=True
1036+
@app.batch_resolver(field_name="listLocations", raise_on_error=True, aggregate=False)
1037+
def create_something(event: AppSyncResolverEvent) -> Optional[list]: # noqa AA03 VNE003
1038+
raise ValueError
1039+
1040+
# Call the implicit handler
1041+
result = app(event, {})
1042+
1043+
# THEN the return must be the Exception Handler error message
1044+
assert result["message"] == "error"
1045+
1046+
1047+
def test_exception_handler_with_batch_resolver_and_no_raise_exception():
1048+
1049+
# GIVEN a AppSyncResolver instance
1050+
app = AppSyncResolver()
1051+
1052+
event = [
1053+
{
1054+
"typeName": "Query",
1055+
"info": {
1056+
"fieldName": "listLocations",
1057+
"parentTypeName": "Post",
1058+
},
1059+
"fieldName": "listLocations",
1060+
"arguments": {},
1061+
"source": {
1062+
"id": "1",
1063+
},
1064+
},
1065+
{
1066+
"typeName": "Query",
1067+
"info": {
1068+
"fieldName": "listLocations",
1069+
"parentTypeName": "Post",
1070+
},
1071+
"fieldName": "listLocations",
1072+
"arguments": {},
1073+
"source": {
1074+
"id": "2",
1075+
},
1076+
},
1077+
{
1078+
"typeName": "Query",
1079+
"info": {
1080+
"fieldName": "listLocations",
1081+
"parentTypeName": "Post",
1082+
},
1083+
"fieldName": "listLocations",
1084+
"arguments": {},
1085+
"source": {
1086+
"id": [3, 4],
1087+
},
1088+
},
1089+
]
1090+
1091+
# WHEN we configure exception handler for ValueError
1092+
@app.exception_handler(ValueError)
1093+
def handle_value_error(ex: ValueError):
1094+
return {"message": "error"}
1095+
1096+
# WHEN the sync batch resolver for the 'listLocations' field is defined with raise_on_error=False
1097+
@app.batch_resolver(field_name="listLocations", raise_on_error=False, aggregate=False)
1098+
def create_something(event: AppSyncResolverEvent) -> Optional[list]: # noqa AA03 VNE003
1099+
raise ValueError
1100+
1101+
# Call the implicit handler
1102+
result = app(event, {})
1103+
1104+
# THEN the return must not trigger the Exception Handler, but instead return from the resolver
1105+
assert result == [None, None, None]

tests/functional/event_handler/required_dependencies/appsync/test_appsync_single_resolvers.py

+22
Original file line numberDiff line numberDiff line change
@@ -329,3 +329,25 @@ async def get_async():
329329
# THEN
330330
assert asyncio.run(result) == "value"
331331
assert app.context == {}
332+
333+
334+
def test_exception_handler_with_single_resolver():
335+
# GIVEN a AppSyncResolver instance
336+
mock_event = load_event("appSyncDirectResolver.json")
337+
338+
app = AppSyncResolver()
339+
340+
# WHEN we configure exception handler for ValueError
341+
@app.exception_handler(ValueError)
342+
def handle_value_error(ex: ValueError):
343+
return {"message": "error"}
344+
345+
@app.resolver(field_name="createSomething")
346+
def create_something(id: str): # noqa AA03 VNE003
347+
raise ValueError("Error")
348+
349+
# Call the implicit handler
350+
result = app(mock_event, {})
351+
352+
# THEN the return must be the Exception Handler error message
353+
assert result["message"] == "error"

0 commit comments

Comments
 (0)