Skip to content

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.

typescript
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:

typescript
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.

typescript
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 context

Additionally, 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

ClassStatusCodeDefault Message
ValidationError400VALIDATION_ERROR(caller-provided)
UnauthorizedError401UNAUTHORIZEDInvalid Authentication token; login
ForbiddenError403FORBIDDENAccess denied
NotFoundError404NOT_FOUND(caller-provided)
DatabaseError500DATABASE_ERRORDatabase operation failed
ServiceUnavailableError503SERVICE_UNAVAILABLE(caller-provided)
UnexpectedError500INTERNAL_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.

typescript
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:

typescript
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:

typescript
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

FunctionPurpose
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

ScenarioUse
External API call that may failResult
Resource not found (expected)Result or NotFoundError
Invalid input from clientthrow ValidationError
Missing auth credentialsthrow UnauthorizedError
Database query failurethrow DatabaseError (automatic via withQueryMetrics)
Unexpected/unrecoverable failurethrow 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):

typescript
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.

typescript
import { sanitizeErrorMessage } from '@mantleframework/errors'

sanitizeErrorMessage('SELECT * FROM users WHERE id = 1')
// Returns: 'Database operation failed'