Skip to content

feat: typed AppSync resolver errors (#385) - #386

Merged
hoegertn merged 8 commits into
mainfrom
feat/appsync-typed-errors
Oct 20, 2026
Merged

hoegertn merged 8 commits into
mainfrom
feat/appsync-typed-errors

Conversation

@hoegertn

@hoegertn hoegertn commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Closes #385.

What this does

Gives createAppSyncHandler typed-error support so expected resolver failures (forbidden, not-found, validation, conflict) surface as GraphQL errors without failing the Lambda invocation, keeping them off the Lambda Errors metric. The resolver-side support is opt-in so upgrading consumers see no change unless they ask for it.

Changes

  • src/lambda/appsync-errors.ts (new): AppSyncError base carrying readonly errorType: string and optional errorInfo, with subclasses ForbiddenError (Forbidden), NotFoundError (NotFound), ValidationError (ValidationError), ConflictError (Conflict). Re-exported from errors.ts as a nested namespace, so consumers use errors.appsync.* and the existing HTTP errors.ForbiddenError / errors.NotFoundError are unchanged.
  • src/lambda/handler.ts: on catching an AppSyncError, createAppSyncHandler logs at warn and returns a sentinel-stamped envelope ({ __appSyncError: true, data: null, errorType, errorMessage, errorInfo }) instead of rethrowing. Untyped errors still log at error and rethrow.
  • src/constructs/graphql.ts: new API-wide typedErrors?: boolean prop on GraphQlApiProps (@default false). When unset, addLambdaResolver synthesizes the pre-createAppSyncHandler: support typed errors so expected failures return GraphQL errors without failing the Lambda invocation #385 plain UNIT direct-Lambda resolver, so upgrading consumers get zero resolver-topology diff. When true, it synthesizes an APPSYNC_JS pipeline resolver whose response handler converts the envelope into a GraphQL error via util.error(message, errorType, null, errorInfo).
  • Tests: test/lambda/appsync-handler.test.ts (handler behavior) and test/constructs/graphql.test.ts (both the default UNIT path and the opt-in PIPELINE path).
  • llm.md: documents the opt-in, that the default is unchanged UNIT resolvers (no cdk diff surprise), and the handler/pipeline matched-pair interaction.
  • docs/experiments/appsync-unit-resolver-error-probe.md: a standalone probe spec used to settle, against a live AppSync API, that a bare UNIT direct-Lambda resolver cannot surface the typed-error envelope on a single Invoke. The probe confirmed the pipeline is required, which is why the opt-in path uses APPSYNC_JS.

Why opt-in

A live AppSync probe confirmed that a UNIT direct-Lambda resolver returns the { errorType, errorMessage } object as field data on a single Invoke rather than mapping it to a GraphQL error. Typed errors therefore require the APPSYNC_JS pipeline, which is a resolver-topology change. Gating it behind typedErrors means consumers who upgrade without opting in keep plain UNIT resolvers and see no cdk diff. Enabling typedErrors updates resolvers in place (logical IDs unchanged) and adds one FunctionConfiguration per Lambda resolver; run cdk diff after enabling.

Compatibility

Additive and backward compatible. Default behavior matches pre-#385 byte-for-byte (verified against commit e868b6a). The typedErrors flag applies to all Lambda resolvers on an API.

Verification

pnpm run build, pnpm run test (128 pass), pnpm run eslint clean, npx projen zero drift.

Handler/resolver coupling (enforced, not just documented)

createAppSyncHandler returns the sentinel envelope only when process.env.APPSYNC_TYPED_ERRORS === 'true', otherwise it rethrows the typed error (invocation fails loudly, same as pre-#385). addLambdaResolver injects APPSYNC_TYPED_ERRORS=true into the resolver Lambda only on the typedErrors: true pipeline path, the same path that attaches the response handler that can translate the envelope. This closes a regression where a handler throwing errors.appsync.* on the default UNIT path would return the raw envelope as field data with the error type silently dropped. Default when the env var is absent is rethrow, so hand-wired handlers used without GraphQlApi stay safe and must set APPSYNC_TYPED_ERRORS=true themselves to get envelope behavior.

Auth / HTTP errors on the AppSync path

createAppSyncHandler also translates HTTP errors (errors.HttpError and subclasses) into typed GraphQL errors, routed through the same APPSYNC_TYPED_ERRORS gate. This covers the errors the shared authorizer in src/lambda/auth.ts throws from assertAuthenticated / assertGroup / assertAdmin, which are expected permission failures on the AppSync path but were previously rethrown as untyped faults. Status-to-errorType mapping: 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, any other Error. auth.ts, the HTTP errors.* classes, and createHttpHandler are unchanged; the translation happens only at the AppSync boundary. Same rethrow-when-not-activated rule applies, so a default UNIT resolver whose handler hits an auth assertion still fails loudly unless typedErrors is active.

Add errors.appsync namespace (AppSyncError base plus ForbiddenError,
NotFoundError, ValidationError, ConflictError). createAppSyncHandler now
returns the direct-Lambda error envelope { data, errorType, errorMessage,
errorInfo } and logs at warn level when a resolver throws an AppSyncError,
so AppSync maps it to a GraphQL error without failing the invocation.
Untyped errors still log at error level and rethrow.

Closes #385
…ndler

addLambdaResolver now creates an APPSYNC_JS pipeline resolver whose
response handler converts the handler error envelope into a GraphQL
error via util.error/util.appendError. A direct-Lambda resolver with no
response handler would surface the { data, errorType, errorMessage,
errorInfo } envelope as field data instead of raising a GraphQL error,
so the typed errors.appsync.* errors never reached the client. The
errorInfo field is wired through util.error's fourth argument.

Document the required resolver wiring and the HTTP-vs-appsync
ForbiddenError/NotFoundError name distinction in llm.md.
The GraphQlApi.addLambdaResolver response handler decided an invocation was
an error from a truthy result.errorMessage, so a resolver whose legitimate
return value had an errorMessage field raised a spurious GraphQL error.

createAppSyncHandler now stamps a reserved __appSyncError sentinel on the
error envelope, and the resolver response handler branches on that sentinel
instead of on errorMessage. Tests pin the sentinel at both ends and the
success passthrough. llm.md documents the resolver topology change and the
cdk diff recommendation for upgrades.
@hoegertn hoegertn changed the title feat: add AppSync typed errors surfaced as GraphQL resolver errors feat: typed AppSync resolver errors (#385) Oct 19, 2026
createAppSyncHandler returned the sentinel envelope unconditionally on an
errors.appsync.* throw. On the default UNIT resolver path (no response handler)
AppSync surfaced the raw envelope as field data and the typed error was lost,
and the Lambda Errors metric never incremented.

Gate the envelope-return on process.env.APPSYNC_TYPED_ERRORS === 'true'.
GraphQlApi.addLambdaResolver injects APPSYNC_TYPED_ERRORS=true only on the
typedErrors:true pipeline path. When the flag is absent, the handler rethrows
the typed error, restoring loud failure on the default path. Untyped errors
always rethrow.
…ppSyncHandler

Map errors.HttpError statusCode to a GraphQL errorType (400 BadRequest,
401 Unauthorized, 403 Forbidden, 404 NotFound, other Error) and route it
through the same APPSYNC_TYPED_ERRORS gate and sentinel envelope as
AppSyncError. HTTP errors thrown by the shared authorizer assertions no
longer fall through to the untyped rethrow when typed errors are active.
@hoegertn
hoegertn merged commit 1312453 into main Oct 20, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

createAppSyncHandler: support typed errors so expected failures return GraphQL errors without failing the Lambda invocation

2 participants