Skip to content
This repository was archived by the owner on Jul 17, 2026. It is now read-only.

Error handling

Eugene Lazutkin edited this page Apr 23, 2026 · 2 revisions

Error handling

The adapter wraps every route handler in a try / catch. Thrown errors are mapped to a status code and serialized into a JSON body. Callers never see stack traces, raw SDK errors, or — critically — the generic AWS Lambda error envelope that fires when an unhandled exception escapes the handler.

Flow

adapter throws / parser rejects
       │
       ▼
is err.status an HTTP status (400–599)?
       │
   yes ┤──► use err.status directly
       │
       ▼ no
mapErrorStatus(err, policy.statusCodes) → status
       │
       ▼
{statusCode: status, body: JSON.stringify(policy.errorBody(err)), headers: {...}}

Sources of thrown errors:

  • The Adapter (CRUD methods, hooks, expression builders, batch retries).
  • The request body / query parsers in rest-core (validation rejections).
  • The adapter itself (unknown method on a known route → 405, bad JSON → 400, oversized body → 413).
  • Your own callbacks — keyFromPath, exampleFromContext, policy.errorBody — anything that throws bubbles into the same path.

Because the dispatch is inherently async and the adapter wraps it in try/catch, there is no escape route — the adapter always returns a Lambda result envelope, never throws out of the top level. This is deliberate: an unhandled throw lets AWS emit its own generic error response ({"message":"Internal server error"} at 502 on API Gateway, a non-2xx on ALB), which is opaque to clients and breaks structured error contracts.

Default status mapping

mapErrorStatus routes common SDK error names to buckets defined in policy.statusCodes:

SDK error name Default status Policy bucket
ConditionalCheckFailedException 409 consistency
TransactionCanceledException 409 consistency
TransactionConflictException 409 consistency
ValidationException / ValidationError 422 validation
ProvisionedThroughputExceededException 429 throttle
RequestLimitExceeded 429 throttle
ItemCollectionSizeLimitExceededException 429 throttle
LimitExceededException 429 throttle
InternalServerError / ServiceUnavailable 503 transient
Other 5xx HTTP from SDK 503 transient
anything else 500 internal

Override any bucket via policy.statusCodes:

createLambdaAdapter(adapter, {
  policy: {
    statusCodes: {
      miss:        404,
      validation:  400,  // 400 instead of 422
      consistency: 409,
      throttle:    503,  // coalesce throttle + transient
      transient:   503,
      internal:    500
    }
  }
});

policy.statusCodes requires all keys because the upstream RestStatusCodes type is non-partial. Supply the full map or start from defaultPolicy.statusCodes:

import {defaultPolicy} from 'dynamodb-toolkit/rest-core';

const statusCodes = {...defaultPolicy.statusCodes, validation: 400};
const handler = createLambdaAdapter(adapter, {policy: {statusCodes}});

Default error body

policy.errorBody defaults to buildErrorBody from rest-core:

{
  "code":    "ConditionalCheckFailedException",
  "message": "The conditional request failed"
}

code falls back to err.codeerr.name'Error'. message falls back to 'Unknown error'.

Throwing your own errors

Any error object with a status property in the 400–599 range passes through to the response as-is. Useful for auth / validation rejections in your own code:

// In a hook:
hooks: {
  prepare(item, isPatch) {
    if (!isPatch && !item.tenantId) {
      throw Object.assign(new Error('tenantId required'), {status: 422, code: 'MissingTenant'});
    }
    return item;
  }
}

// In keyFromPath:
createLambdaAdapter(adapter, {
  keyFromPath: raw => {
    if (!/^[a-z0-9-]+$/.test(raw)) {
      throw Object.assign(new Error('Invalid key format'), {status: 400, code: 'BadKey'});
    }
    return {[adapter.keyFields[0].name]: raw};
  }
});

// In exampleFromContext:
createLambdaAdapter(adapter, {
  exampleFromContext: (_q, _b, event) => {
    const tenantId = event.requestContext?.authorizer?.jwt?.claims?.tenant_id;
    if (!tenantId) {
      throw Object.assign(new Error('missing tenant'), {status: 401, code: 'MissingTenant'});
    }
    return {tenantId};
  }
});

err.code sets the code field in the response body; err.message sets message.

Including debug info in development

buildErrorBody accepts an includeDebug flag that attaches err.stack. Wire it via a custom policy.errorBody:

import {buildErrorBody} from 'dynamodb-toolkit/rest-core';

const isDev = process.env.STAGE === 'dev' || process.env.AWS_SAM_LOCAL === 'true';

createLambdaAdapter(adapter, {
  policy: {
    errorBody: err => buildErrorBody(err, {includeDebug: isDev})
  }
});

Also supports per-error correlation IDs via the errorId option:

policy: {
  errorBody: err => buildErrorBody(err, {errorId: crypto.randomUUID()})
}

Note that policy.errorBody receives only err; neither the event nor the Lambda context is threaded through. For per-request IDs that need to match the Lambda's own awsRequestId, either:

  • Generate a random ID inside the error body (above).
  • Wrap the handler to inject Lambda-context-scoped data (below).

Wrapping the handler for request-context error bodies

If you need context.awsRequestId or event data in the error body, wrap the handler:

import {createLambdaAdapter} from 'dynamodb-toolkit-lambda';

const inner = createLambdaAdapter(adapter);

export const handler = async (event, context) => {
  const result = await inner(event, context);

  // Enrich non-2xx JSON bodies with the Lambda request ID.
  const ct = result.headers?.['content-type'] ?? '';
  if (result.statusCode >= 400 && ct.includes('application/json') && result.body) {
    try {
      const body = JSON.parse(result.body);
      return {
        ...result,
        body: JSON.stringify({...body, requestId: context.awsRequestId})
      };
    } catch {
      // Leave non-JSON 4xx/5xx bodies alone.
    }
  }
  return result;
};

This keeps the adapter's default error-body logic intact and layers the Lambda request ID on top. Clients can quote requestId in a bug report → you find the Lambda invocation in CloudWatch with no hunting.

405 on wrong method

When the route shape is recognized but the method isn't supported (e.g. POST /:key), the adapter responds with:

HTTP/1.1 405 Method Not Allowed
{
  "code": "MethodNotAllowed",
  "message": "Method not allowed for this route"
}

Unrecognized routes and paths outside mountPath

Empty 404 with no body. Lambda has no equivalent of Fetch's onMiss composition hook — the handler is always terminal.

If you want a custom 404 body, wrap the handler and substitute it:

const inner = createLambdaAdapter(adapter, {mountPath: '/planets'});

export const handler = async (event, context) => {
  const result = await inner(event, context);
  if (result.statusCode === 404 && (!result.body || result.body === '')) {
    return {
      statusCode: 404,
      body: JSON.stringify({code: 'NotFound', path: event.rawPath ?? event.path}),
      headers: {'content-type': 'application/json; charset=utf-8'}
    };
  }
  return result;
};

Or set policy.statusCodes.miss if you only want to change the item-not-found response on GET /:key (the adapter-level miss path, not the off-route path).

413 / 400 from body reading

readJsonBody throws structured errors that flow through the same error path:

Condition Status code
Decoded body over cap 413 PayloadTooLarge
Invalid JSON body 400 BadJsonBody
PUT /-load not an array 400 BadLoadBody

See Body reading for the full mechanism.

Interaction with Lambda Destinations / DLQ

Since the adapter always returns a valid result envelope, Lambda Destinations / DLQ on failure never fire for adapter-routed requests. If you want to trigger them on, say, consistency errors, you must re-throw from a wrapper after inspecting the result — but don't do this by default. Returning a 4xx/5xx HTTP response with a structured body is the right behavior for HTTP-fronted Lambdas; DLQ is for internal event-driven work.

Clone this wiki locally