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 or raw SDK errors unless you opt in.

Flow

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.

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:

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}});

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:
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].name]: raw};
  }
});

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 = 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).

Wrapping the handler for request-context error bodies

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.

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

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: empty 404 Response. Terminal.
  • With onMiss: hook decides. Return null to yield control to a host router, Response to ship one, undefined to fall back to the default 404.

See Composition for when to use each shape.

413 / 400 from body reading

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.

Clone this wiki locally