appsync
AppSync Lambda Functions
While APPSYNC_JS resolvers provide high performance for direct data-source mapping, writing all business and validation logic exclusively within JS resolvers is not always practical. Many real-world applications require more flexibility—such as querying external databases via ORMs, interacting with third-party APIs, or leveraging complex validation libraries—which can only be achieved inside AWS Lambda functions.
How AppSync Invokes Lambda
In AWS AppSync, Lambda functions are never invoked directly by the client; they are always executed through an AppSync Resolver (either a Unit Resolver or a Pipeline Resolver step) configured with a Lambda Data Source:
- Request Phase: The resolver prepares the invoke payload and calls the Lambda Data Source.
- Lambda Execution: The Lambda function executes your validation and business logic.
- Response Phase: The resolver receives the Lambda output (
ctx.result) or execution error (ctx.error) and formats the GraphQL response.
The AppSync Lambda Error Challenge
When a Lambda function throws an uncaught exception (e.g. throw new Error(...)), AppSync intercepts the failure at the resolver level with significant limitations:
- Generic Error Types: AppSync automatically marks the error type as
Lambda:Unhandled(orLambdaUnhandled). - No Structured Metadata: AppSync only extracts the raw string message (
ctx.error.message). No structurederrorInfo(such as field paths, rejected values, or granular validation error lists) is shared with the client. - Strict GraphQL Schema Constraints: Because GraphQL enforces strict return types for fields, the Lambda function cannot simply return an arbitrary error object as normal data without violating the schema.
The Solution: The AppSyncError Bridge
To communicate rich validation error details (including field paths, error codes, and messages) back to the GraphQL client without triggering unhandled Lambda failures:
- Lambda returns a structured error payload: Instead of throwing an uncaught exception, the Lambda function returns an
AppSyncErrorstructure inside the invocation result (ctx.result.error). - The Resolver transforms the payload: The AppSync resolver's response handler inspects
ctx.result. When it identifies anAppSyncErrorobject, it intercepts the result and converts each entry into native GraphQL errors using$util.appendError()and$util.error().
The @sot1986/appsync-precognition/middy package provides the tools to implement this workflow effortlessly across your Amplify backend and Lambda functions.
Overriding the Lambda Resolver Response Template in Amplify
In AWS Amplify (Gen 2), resolvers connected to Lambda data sources are provisioned using default VTL templates. These standard templates only check for runtime crashes ($ctx.error) and do not know how to unpack structured AppSyncError payloads.
To enable rich error mapping, use the resolveLambdaResponseTemplate() helper in your amplify/backend.ts file to override the response mapping template across all Lambda function resolvers in the AppSync GraphQL API stack:
import { defineBackend } from '@aws-amplify/backend'
import { resolveLambdaResponseTemplate } from '@sot1986/appsync-precognition/middy'
import { CfnFunctionConfiguration } from 'aws-cdk-lib/aws-appsync'
import { auth } from './auth/resource'
import { data } from './data/resource'
import { registerUserHandler } from './functions/register-user/resource'
const backend = defineBackend({
auth,
data,
registerUserHandler,
})
// Override response mapping templates for all VTL Lambda function resolvers
backend.data.resources.graphqlApi.stack.node.findAll().forEach((child) => {
if (child instanceof CfnFunctionConfiguration && !child.runtime) {
child.responseMappingTemplate = resolveLambdaResponseTemplate()
child.responseMappingTemplateS3Location = undefined
child.addPropertyDeletionOverride('ResponseMappingTemplateS3Location')
}
})
What resolveLambdaResponseTemplate() Generates
Under the hood, resolveLambdaResponseTemplate() outputs the following Velocity Template Language (VTL) logic:
## [Start] Handle error or return result for request response invocation type. **
#if( $ctx.error )
$util.error($ctx.error.message, $ctx.error.type)
#end
## [Custom Error Handling]
#if( $ctx.result.error && $ctx.result.error.type == 'AppSyncError' && $ctx.result.error.errorsCount > 0 )
#set( $errIndex = 0 )
#foreach( $err in $ctx.result.error.errors )
#set( $errIndex = $errIndex + 1 )
#if( $errIndex < $ctx.result.error.errorsCount )
$util.appendError($err.message, $err.errorType, null, $err.errorInfo)
#else
$util.error($err.message, $err.errorType, null, $err.errorInfo)
#end
#end
#end
$util.toJson($ctx.result)
## [End] Handle error or return result for request response invocation type. **
- Runtime Crashes: If the Lambda unhandled crash occurs,
$ctx.errortriggers a standard AppSync error. - Structured Error Bridge: If
$ctx.result.error.type == 'AppSyncError', the template iterates through all errors, appending each error with its specificerrorTypeanderrorInfo(e.g.{ path: "email", value: "invalid" }). - Success: If there are no errors,
$util.toJson($ctx.result)returns the Lambda result directly to the GraphQL execution engine.
The Error Interface & AppSyncError
Sharing error details between AWS Lambda and AppSync resolvers is only possible if the Lambda invocation result matches the exact interface expected by the resolver response template:
interface AppSyncErrorPayload {
error: {
type: 'AppSyncError'
errors: Array<{
message: string
errorType: string
errorInfo?: Record<string, any> | null
}>
errorsCount: number
}
}
The AppSyncError Base Class
To satisfy this contract, the package exports the AppSyncError class (which implements AppSyncMappedError).
If you want a common error interface across both AppSync JS Resolvers and Lambda functions, all custom business errors thrown inside your Lambda function should extend AppSyncError (or implement the errorItems() method):
import { AppSyncError } from '@sot1986/appsync-precognition/middy'
export class NotFoundError extends AppSyncError {
constructor(message: string = 'Resource not found', resourceId?: string) {
super(message, 'NotFoundError', resourceId ? { id: resourceId } : undefined)
}
}
export class UnauthorizedError extends AppSyncError {
constructor(message: string = 'Unauthorized access') {
super(message, 'UnauthorizedError')
}
}
The appsyncErrorHandler Middy Middleware
To manage cross-cutting concerns (such as error handling, validation, and precognitive checks) cleanly without cluttering your core business logic, we use Middy (@middy/core), the de facto standard middleware engine for AWS Lambda in Node.js environments.
The module exports the appsyncErrorHandler() middleware for Middy. It automatically intercepts any error thrown during Lambda execution that subclasses AppSyncError (or implements AppSyncMappedError), converts it into the expected AppSyncError payload, and sets request.error = null so the Lambda execution completes cleanly.
The AppSync resolver can then parse the payload and proxy the structured errors to the GraphQL client:
import middy from '@middy/core'
import { AppSyncError, appsyncErrorHandler } from '@sot1986/appsync-precognition/middy'
async function baseHandler(event: any) {
const item = await findItem(event.arguments.id)
if (!item) {
// Thrown AppSyncError is captured by appsyncErrorHandler
throw new AppSyncError('Item not found', 'NotFoundError', { id: event.arguments.id })
}
return item
}
export const handler = middy(baseHandler)
.use(appsyncErrorHandler())
Full Example: Validating with Zod
By combining precognition() and appsyncErrorHandler(), you can create a reusable Middy validation middleware that converts Zod schema errors directly into AppSyncError validation responses.
1. Create the Reusable Zod Middleware
Create a parser helper that wraps precognition() and maps z.ZodError issues into standard validation paths and messages:
import type middy from '@middy/core'
import { precognition } from '@sot1986/appsync-precognition/middy'
import * as z from 'zod'
export function parser<TSchema extends z.ZodType, TResult>(options: {
schema: TSchema
}): middy.MiddlewareObj<z.infer<TSchema>, TResult> {
return precognition({
validator: event => options.schema.parse(event),
toValidationErrors: (error) => {
if (error instanceof z.ZodError === false)
return null
return error.issues.map((issue) => {
// Strip top-level 'arguments' prefix so path matches form keys (e.g. 'email' instead of 'arguments.email')
const path = issue.path.at(0) === 'arguments' ? issue.path.slice(1) : issue.path
return {
path: path.map(String),
message: issue.message,
value: issue.input,
}
})
},
})
}
2. Implement the Lambda Handler
Use the parser middleware alongside appsyncErrorHandler() in your Lambda function:
import middy from '@middy/core'
import { appsyncErrorHandler } from '@sot1986/appsync-precognition/middy'
import * as z from 'zod'
import { parser } from '../_middlewares/validate'
// 1. Define the input validation schema
const CreateUserEventSchema = z.object({
arguments: z.object({
email: z.string().email('Please enter a valid email address'),
name: z.string().min(3, 'Name must be at least 3 characters'),
age: z.number().min(18, 'Must be at least 18 years old'),
}),
})
// 2. Base handler (only runs if validation passes and request is NOT precognitive)
async function baseHandler(event: z.infer<typeof CreateUserEventSchema>) {
const { email, name, age } = event.arguments
// Perform database write / business logic
return {
id: 'user_123',
email,
name,
age,
createdAt: new Date().toISOString(),
}
}
// 3. Compose with Middy
export const handler = middy(baseHandler)
.use(appsyncErrorHandler())
.use(parser({ schema: CreateUserEventSchema }))
What Happens Behind the Scenes:
- Precognitive Request: If headers contain
Precognition: true, theparsermiddleware validates the schema. On success, it immediately returns{ data: null }withPrecognition-Success: true, skippingbaseHandler. - Validate-Only: If
Precognition-Validate-Only: emailis sent, only errors on theemailfield will be reported back. - Validation Errors: When Zod throws a
ZodError,parserconverts it toPrecognitionValidationError(a subclass ofAppSyncError), whichappsyncErrorHandlertransforms into{ error: { type: 'AppSyncError', errors, errorsCount } }. - GraphQL Error Response: The AppSync resolver response template unpacks this error structure with
$util.appendError(), delivering granular field errors to your Nuxt frontend form.
Summary Flow Chart
The following diagram illustrates the complete end-to-end lifecycle of a request across the AppSync resolver, Lambda function, Middy middleware pipeline, and the client:
Client dispatches a GraphQL mutation with Precognition headers (Precognition, Precognition-Validate-Only).
Resolver receives the GraphQL request and invokes the AWS Lambda Data Source passing the context payload ($ctx.args and request headers).
The precognition() middleware validates input. If validation fails or business checks fail, an AppSyncError (or PrecognitionValidationError) is thrown.
The appsyncErrorHandler() middleware catches the error, formats it into the structured result payload { error: { type: 'AppSyncError', errors, errorsCount } }, and clears request.error so Lambda succeeds without crashing.
The VTL response template inspects $ctx.result.error, recognizes AppSyncError, and iterates calling $util.appendError() and $util.error() with errorType and errorInfo.
The Nuxt client receives a standard GraphQL error response with full validation metadata in errorInfo, seamlessly binding error messages to each corresponding form input.
Flow Steps Breakdown
- Client Request: The client dispatches a GraphQL mutation along with Precognition HTTP headers (
Precognition,Precognition-Validate-Only). - Resolver Invocation: The AppSync resolver request mapping forwards the request arguments and headers to the Lambda Data Source.
- Middy Validation: The
precognition()middleware validates the input before the handler runs. If validation fails or business logic encounters an issue, anAppSyncError(orPrecognitionValidationError) is thrown. - Error Transformation: The
appsyncErrorHandlermiddleware catches the error and formats it into the expected{ error: { type: 'AppSyncError', errors, errorsCount } }structure, completing the Lambda invocation cleanly without crashing. - Lambda Response: The Lambda function returns this structured object as its invocation result (
$ctx.result). - Resolver Error Mapping: The AppSync resolver response template (
resolveLambdaResponseTemplate()) detectstype == 'AppSyncError', loops overerrors, and invokes$util.appendError()/$util.error(). - Client Error Handling: AppSync delivers a standard GraphQL errors response with complete
errorTypeanderrorInfometadata, which the Nuxt Precognition client parses and maps directly to the corresponding form fields.