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 @j0nathan-ll0yd/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 '@j0nathan-ll0yd/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 '@j0nathan-ll0yd/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 @j0nathan-ll0yd/core instead of throwing.

typescript
import { ok, err, type Result } from '@j0nathan-ll0yd/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 '@j0nathan-ll0yd/core'
import { fetchWithTimeout } from '@j0nathan-ll0yd/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 '@j0nathan-ll0yd/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 '@j0nathan-ll0yd/errors'

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