-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
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}});policy.errorBody defaults to buildErrorBody from rest-core:
{
"code": "ConditionalCheckFailedException",
"message": "The conditional request failed"
}code falls back to err.code → err.name → 'Error'. message falls back to 'Unknown error'.
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.
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).
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.
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"
}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).
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.
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.