How to handle client errors gracefully with AppSync and Lambda

theburningmonk

Yan Cui

Posted on June 7, 2021

How to handle client errors gracefully with AppSync and Lambda

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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')
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

And voila! You have successfully handled a client error gracefully.

💖 💪 🙅 🚩
theburningmonk
Yan Cui

Posted on June 7, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related