-
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 or raw SDK errors unless you opt in.
adapter throws / parser rejects
│
▼
is err.status an HTTP status (400–599)?
│
yes ┤──► use err.status directly
│
▼ no
mapErrorStatus(err, policy.statusCodes) → status
│
▼
new Response(JSON.stringify(policy.errorBody(err)), {status, 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).
Because Fetch handlers are inherently async and the adapter is wrapped in a try/catch, there is no equivalent to Express's next(err) escape hatch — the adapter always returns a Response, never throws out of the top level.
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:
createFetchAdapter(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 = createFetchAdapter(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:
createFetchAdapter(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]]: raw};
}
});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 = globalThis.process?.env?.NODE_ENV !== 'production'; // Node/Bun
// or on Cloudflare Workers: const isDev = env.ENVIRONMENT === 'dev';
createFetchAdapter(adapter, {
policy: {
errorBody: err => buildErrorBody(err, {includeDebug: isDev})
}
});Also supports per-request correlation IDs via the errorId option:
policy: {
errorBody: err => buildErrorBody(err, {errorId: crypto.randomUUID()})
}Note that policy.errorBody receives only err; the original Request isn't threaded through. For per-request IDs that need to match against application logs, either:
- Generate the ID inside the error body (above — random per error).
- Wrap the handler to inject a request-scoped context (below).
If you need request context in the error body (user ID, request ID, locale, Worker trace ID), wrap the handler:
const inner = createFetchAdapter(adapter);
export default {
async fetch(request) {
const requestId = request.headers.get('x-request-id') || crypto.randomUUID();
const response = await inner(request);
// Enrich 4xx/5xx JSON bodies with requestId; leave success responses untouched.
if (!response.ok && response.headers.get('content-type')?.includes('application/json')) {
const body = await response.json();
return new Response(JSON.stringify({...body, requestId}), {
status: response.status,
headers: response.headers
});
}
return response;
}
};This keeps the adapter's default error-body logic intact and layers request context on top.
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"
}Paths that don't match any shape in matchRoute (e.g. three-segment paths) and paths outside mountPath go through the miss path:
- Without
onMiss: empty404 Response. Terminal. - With
onMiss: hook decides. Returnnullto yield control to a host router,Responseto ship one,undefinedto fall back to the default 404.
See Composition for when to use each shape.
readJsonBody throws structured errors that flow through the same error path:
| Condition | Status | code |
|---|---|---|
| Content-Length over cap | 413 |
PayloadTooLarge |
| Body streamed past cap | 413 |
PayloadTooLarge |
| Invalid JSON body | 400 |
BadJsonBody |
PUT /-load not an array |
400 |
BadLoadBody |
See Body reading for the full mechanism.