Error Handling
Mantle provides a structured error hierarchy for Lambda handlers and a Result type for service-layer operations that can fail without throwing.
Error Hierarchy
All HTTP-facing errors extend CustomLambdaError from @mantleframework/errors:
CustomLambdaError (base)
+-- ValidationError (400)
+-- UnauthorizedError (401)
+-- ForbiddenError (403)
+-- NotFoundError (404)
+-- DatabaseError (500, sanitized)
+-- ServiceUnavailableError (503)
+-- UnexpectedError (500)Each subclass sets statusCode, code, and name automatically. The base class supports error chaining via cause and structured metadata via withContext().
Throwing Errors in Handlers
Throw a CustomLambdaError subclass from any handler. The defineApiHandler catch boundary calls buildErrorResponse() internally, which maps the error to the correct HTTP status code and sanitizes the message.
import { NotFoundError, ValidationError } from '@mantleframework/errors'
// Inside a defineApiHandler handler:
const book = await findByAsin(body.asin)
if (!book) {
throw new NotFoundError(`Book not found: ${body.asin}`)
}You do not need to call buildErrorResponse() directly in application code -- defineApiHandler handles it at its catch boundary.
Error Context
Attach structured metadata to any error for debugging and observability:
throw new NotFoundError('Book not found')
.withContext({ asin: body.asin, requestedBy: userId })buildErrorResponse() automatically enriches errors with correlation IDs, trace IDs, Lambda name, timestamp, and request path.
DatabaseError Sanitization
DatabaseError never exposes SQL to clients. The constructor accepts the query name and original error, but the client-facing message is always "Database operation failed". The original message is preserved in originalMessage for server-side logging.
import { DatabaseError } from '@mantleframework/errors'
// Inside withQueryMetrics (automatic):
throw new DatabaseError('Books.findByAsin', originalError)
// Client sees: { "message": "Database operation failed" }
// Logs contain: originalMessage with full SQL contextAdditionally, sanitizeErrorMessage() strips SQL patterns from any error message before it reaches the client, catching cases where a non-DatabaseError leaks SQL content.
Error Classes Reference
| Class | Status | Code | Default Message |
|---|---|---|---|
ValidationError | 400 | VALIDATION_ERROR | (caller-provided) |
UnauthorizedError | 401 | UNAUTHORIZED | Invalid Authentication token; login |
ForbiddenError | 403 | FORBIDDEN | Access denied |
NotFoundError | 404 | NOT_FOUND | (caller-provided) |
DatabaseError | 500 | DATABASE_ERROR | Database operation failed |
ServiceUnavailableError | 503 | SERVICE_UNAVAILABLE | (caller-provided) |
UnexpectedError | 500 | INTERNAL_ERROR | (caller-provided) |
Result Type
For service-layer functions where failure is an expected outcome (not exceptional), use the Result<T, E> discriminated union from @mantleframework/core instead of throwing.
import { ok, err, type Result } from '@mantleframework/core'Result<T, E> is a union of { ok: true, value: T } and { ok: false, error: E }. This forces callers to handle the error path explicitly.
Returning Results from Services
A real production example from mantle-LifegamesPortal/src/services/scrapingDogService.ts:
import { err, ok, type Result } from '@mantleframework/core'
import { fetchWithTimeout } from '@mantleframework/resilience'
export async function fetchBookData(asin: string, apiKey: string): Promise<Result<ScrapedBookData>> {
try {
const response = await fetchWithTimeout(url, { timeoutMs: 20_000 })
if (!response.ok) {
return err(new Error(`ScrapingDog API error: ${response.status}`))
}
const data = (await response.json()) as ScrapingDogResponse
return ok({ asin, title: cleanTitle(parseTitle(data.title)), /* ... */ })
} catch (error) {
return err(error instanceof Error ? error : new Error(String(error)))
}
}Unwrapping Results in Handlers
The caller checks .ok and decides how to handle the error. From mantle-LifegamesPortal/src/lambdas/api/books/add.post.ts:
const bookDataResult = await fetchBookData(body.asin, scrapingDogApiKey)
if (!bookDataResult.ok) {
logError('Failed to fetch book data', { asin: body.asin, error: bookDataResult.error.message })
throw bookDataResult.error // converts to HTTP error via defineApiHandler catch boundary
}
book = await insertBook(bookDataResult.value)Result Utilities
| Function | Purpose |
|---|---|
ok(value) | Create a successful result |
err(error) | Create a failed result |
isOk(result) | Type guard for success |
isErr(result) | Type guard for failure |
unwrap(result) | Extract value or throw error |
mapResult(result, fn) | Transform the success value |
When to Use Result vs Throw
| Scenario | Use |
|---|---|
| External API call that may fail | Result |
| Resource not found (expected) | Result or NotFoundError |
| Invalid input from client | throw ValidationError |
| Missing auth credentials | throw UnauthorizedError |
| Database query failure | throw DatabaseError (automatic via withQueryMetrics) |
| Unexpected/unrecoverable failure | throw UnexpectedError |
Rule of thumb: if the caller is expected to handle the failure gracefully (retry, fallback, user message), return a Result. If the failure should abort the request with an HTTP error, throw a CustomLambdaError subclass.
Utility Functions
getErrorMessage
Extracts a human-readable message from any thrown value (Error, string, or unknown object):
import { getErrorMessage } from '@mantleframework/errors'
try { /* ... */ } catch (error) {
const message = getErrorMessage(error) // always a string
}sanitizeErrorMessage
Strips SQL patterns from error messages before they reach clients. Called automatically by buildErrorResponse() -- you rarely need to call it directly.
import { sanitizeErrorMessage } from '@mantleframework/errors'
sanitizeErrorMessage('SELECT * FROM users WHERE id = 1')
// Returns: 'Database operation failed'