With API Gateway and Lambda, you can handle client errors gracefully by returning a 4xx response.
module.exports.handler = async (event) => {
// run validation logic
return {
statusCode: 400
}
}
This way, we can communicate clearly to the client that there’s a problem with its request. It also lets the Lambda invocation complete successfully, so the invocation doesn’t count as erroneous. This means it wouldn’t trigger any error alerts you have on your Lambda functions.
Unfortunately, when it comes to AppSync and Lambda, we don’t have this ability anyway. Your function has to either return a valid response or throw an error.
This is problematic as client errors would cause your alerts to trigger and you end up wasting time investigating false alerts and eventually develop alert fatigue and become desensitized to these alerts.
The Workaround
The workaround is to mimic what we’d do with API Gateway and have the Lambda function return a specific response, such as:
{
error: {
message: "blah blah",
type: "SomeErrorType"
}
}
and use a custom response VTL template to turn this into a GraphQL error:
if (!$util.isNull($ctx.result.error))
$util.error($ctx.result.error.message, $ctx.result.error.type)
#end
$utils.toJson($ctx.result)
This way, the Lambda invocation was still deemed successful and wouldn’t trigger any alerts on Lambda errors.
However, it can still present a control-flow challenge. Because you have to always return something to the top-level function handler instead of just throwing an error.
Consider this example:
module.exports.handler = async (event) => {
const resp = await doSomething(event)
return resp
}
async function doSomething(event) {
doValidation(event)
// do something useful here
return something
}
function doValidation(event) {
if (event.arguments.answer !== 42) {
throw new Error('wrong answer')
}
}
This isn’t what we want! We don’t want to err the Lambda invocation because the client sent in an invalid request.
One approach would be to capture the error state explicitly and always return something:
module.exports.handler = async (event) => {
const resp = await doSomething(event)
return resp
}
async function doSomething(event) {
const validationResp = doValidation(event)
if (validationResp.error) {
return validationResp.error
}
// do something useful here
return something
}
function doValidation(event) {
if (event.arguments.answer !== 42) {
return {
error: {
message: "wrong answer",
type: "ValidationError"
}
}
} else {
return {}
}
}
While capturing error state explicitly and maintaining referential transparency is a good thing, it’s just not very convenient or idiomatic in languages like JavaScript.
Instead, when working with Node.js functions, I prefer to use a middy middleware to intercept specific errors and handle them.
For example, I’d define a custom error type such as the ValiationError
type below.
class ValidationError extends Error {
constructor(message) {
super(message)
this.name = this.constructor.name
// This clips the constructor invocation from the stack trace
// it makes the stack trace a little nicer
Error.captureStackTrace(this, this.constructor)
}
}
And the middleware would handle this specific error in the onError
handler.
module.exports = () => {
return {
onError: async (request) => {
if (request.error instanceof ValidationError) {
// the response vtl template handles this case
// where the response is { error: { message, type } }
request.response = {
error: {
message: request.error.message,
type: "ValidationError"
}
}
return request.response
}
}
}
}
And now, I can just a ValidationError
from anywhere in my code and the error would not fail the Lambda invocation. Instead, it will be turned into a successful response:
{
error: {
message: "...",
type: "ValidationError"
}
}
And the response VTL template would turn it into a GraphQL error.
if (!$util.isNull($ctx.result.error))
$util.error($ctx.result.error.message, $ctx.result.error.type)
#end
$utils.toJson($ctx.result)
And voila! You have successfully handled a client error gracefully.
Top comments (0)