How to handle client errors gracefully with AppSync and Lambda

Yan Cui

I help clients go faster for less using serverless technologies.

This article is brought to you by

MongoDB 8.0 is here to change the game. Faster reads and inserts, and brand-new vector search to support modern AI-powered apps.

Learn More

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.

Whenever you’re ready, here are 3 ways I can help you:

  1. Production-Ready Serverless: Join 20+ AWS Heroes & Community Builders and 1000+ other students in levelling up your serverless game. This is your one-stop shop for quickly levelling up your serverless skills.
  2. I help clients launch product ideas, improve their development processes and upskill their teams. If you’d like to work together, then let’s get in touch.
  3. Join my community on Discord, ask questions, and join the discussion on all things AWS and Serverless.