Error handling in AppSync

How to use $util.error and $util.appendError in AppSync resolvers

Author's image
Tamás Sallai
5 mins

AppSync provides two ways to signal an error: $util.error and $util.appendError. They behave differently and they allow different use-cases.

In this article we’ll look into how unit resolvers and pipelines can use these two methods and what are the best practices.

Unit resolvers

By default, AppSync does not raise the error if the data source returned one. This is useful as it allows you, the developer, to decide how to handle it. On the other hand, it’s not necessarily apparent that without extra code errors won’t be reported in the response.

Let’s say the response mapping template only returns the result:

$util.toJson($ctx.result)

If there is an error, the result will only contain null and no error information is shown:

{
	"data": {
		"unit_no_error_checking": null
	}
}

$util.error

To throw an error when the data source returned one, use this code in the response template:

#if($ctx.error)
	$util.error($ctx.error.message, $ctx.error.type)
#end
$util.toJson($ctx.result)

The data source populates the $ctx.error if there was an error and this code rethrows that using the $util.error. If there was no error, it returns the $ctx.result.

The $util.error can also be used in the request mapping. In this case, the data source is not called and AppSync returns the error.

{
	"data": {
		"unit_req_error": null
	},
	"errors": [
		{
			"path": [
				"unit_req_error"
			],
			"data": null,
			"errorType": null,
			"errorInfo": null,
			"locations": [
				{
					"line": 2,
					"column": 3,
					"sourceName": null
				}
			],
			"message": "Error from request template"
		}
	]
}

Notice that the field is null, but the error describes what went wrong.

$util.appendError

The $util.appendError does not halt the processing, it just adds an error to the error list. When AppSync returns the response, all the errors that the resolver accumulated (as there can be multiple $util.appendErrors) will be reported as well as the result value.

{
	"data": {
		"unit_append": "val1"
	},
	"errors": [
		{
			"path": [
				"unit_append"
			],
			"message": "error appended in the request template"
		},
		{
			"path": [
				"unit_append"
			],
			"message": "error appended in the response"
		}
	]
}

In this response, there are two errors appended, one in the request, and one in the response templates. Since $util.appendError does not terminate processing, the data source ran and the response contains the value.

Usually, it’s a best practice to use $util.error in unit resolvers and terminate early.

Pipeline resolvers

Pipeline resolvers are made of multiple resolvers, called functions, and AppSync runs them in a pipeline, each getting the result of the previous step. They are great when a value needs multiple operations, which is especially the case for mutations. And as the units in the pipeline need to define a choreography, error handling becomes much more nuanced and the difference between $util.error and $util.appendError more important.

$util.error

The $util.error terminates the pipeline and AppSync returns only the error thrown. This is similar to how throw works in most programming languages, except there is no catch block here. This means there is no error recovery, and no value for the field either.

For example, the first function throws an error if the data source returned one. This terminates the flow and AppSync returns.

Function 1operation1$ctx.errornoyes$util.toJson($ctx.result)$util.error()Function 2operation2$util.toJson($ctx.result)$util.error terminates the pipeline

Calling this pipeline produces this result:

{
	"data": {
		"pipeline_throw": null
	},
	"errors": [
		{
			"path": [
				"pipeline_throw"
			],
			"message": "error1"
		}
	]
}

Notice that the result value is null. Looking into the logs shows that the field execution is stopped (the End Field Execution message is next):

$util.appendError

Appending errors work similar to how it works for unit resolvers. AppSync keeps track of the errors appended and it returns them in the response, but otherwise it does not terminate the execution.

Function 1operation1$ctx.errornoyes$util.toJson($ctx.result)$util.appendError()Function 2operation2$util.toJson($ctx.result)$util.appendError does not terminate the pipeline

This means AppSync can return a value as well as an error:

{
	"data": {
		"pipeline_append": "val1"
	},
	"errors": [
		{
			"path": [
				"pipeline_append"
			],
			"message": "error1"
		}
	]
}

It can return multiple errors as well, accumulated over multiple steps in request and response templates:

{
	"data": {
		"pipeline_append_multiple": "val1"
	},
	"errors": [
		{
			"path": [
				"pipeline_append_multiple"
			],
			"message": "error1"
		},
		{
			"path": [
				"pipeline_append_multiple"
			],
			"message": "error2"
		}
	]
}

$ctx.outErrors

Steps can also check previous errors in the context. These errors are available in the $ctx.outErrors, which is an array containing all previously appended errors.

Function 1$ctx.outError = []operation1$util.appendError("error1")Function 2$ctx.outErrors = ["error1"]operation2$util.toJson($ctx.result)$ctx.outErrors keep track of current errors

This is especially useful to see if there were errors previously and that can change how a function runs.

Use case: Cleanup resources

A common requirement is to provide cleanup for resources created in a previous step.

For example, creating a new user might need two steps:

  • creating a user in a Cognito User Pool
  • updating the database with the newly created entity

This requires a pipeline, as these two operations require different data sources. But what happens if one operation succeeds but the other fails? A cleanup step would help to prevent junk data from remaining:

  • create a user in a Cognito User Pool
  • update the database
  • (if the database update fails) delete the user

Note that it does not eliminate the possibility of having a user in the Cognito User Pool that is not in the database as the cleanup step can also fail. In that case there is not much you can do.

To implement this, we need a combination of $util.error and $util.appendError.

Function 1Create user$ctx.errornoyes$util.toJson($ctx.result)$util.error()Function 2Update database$ctx.errornoyes$util.toJson($ctx.result)$util.appendError()Function 3Delete user$util.error()yes$ctx.outErrors.size() > 0noCleanup resources

If creating a user fails, the whole pipeline is terminated ($util.error). If the database update fails, it only appends an error ($util.appendError). Then the third steps checks errors ($ctx.outErrors.size() > 0) and deletes the user and rethrows the error.

Conclusion

Error handling in AppSync uses two methods: $util.error and $util.appendError. Both adds an errors field to the response, but how they behave is different. $util.error terminates the resolver and the whole pipeline and returns the error immediately. On the other hand, $util.appendError adds the error to a collection but does not terminate execution.

15 February 2022
In this article