Error Handling
Stacks.js provides a comprehensive error handling system that catches exceptions, renders appropriate error responses, logs errors, and optionally reports to external services like Sentry.
Protocol context — This guide covers Stacks.js error behavior in relation to the draft Errors as contract data and Request lifecycle sections.
Configuration
Error Handling Configuration
// config/error-handling.ts
export default {
// Show detailed errors (disable in production)
debug: process.env.NODE_ENV === 'development',
// Error reporting
reporting: {
// Log all errors
log: true,
// External services
sentry: {
enabled: process.env.SENTRY_DSN !== undefined,
dsn: process.env.SENTRY_DSN,
environment: process.env.APP_ENV,
tracesSampleRate: 0.1,
},
// Slack notifications for critical errors
slack: {
enabled: process.env.SLACK_ERROR_WEBHOOK !== undefined,
webhook: process.env.SLACK_ERROR_WEBHOOK,
levels: ['error', 'critical'],
},
},
// Error rendering
render: {
// Custom error views
views: {
404: 'errors/404',
500: 'errors/500',
503: 'errors/maintenance',
},
// JSON errors for API
json: {
includeTrace: false, // Never in production
includeCode: true,
},
},
// Errors to ignore
ignore: [
'ValidationException',
'AuthenticationException',
'NotFoundHttpException',
],
}
Exception Handler
Global Exception Handler
// app/Exceptions/Handler.ts
import { ExceptionHandler, HttpException } from '@stacksjs/error-handling'
export default class Handler extends ExceptionHandler {
// Exceptions that should not be reported
protected dontReport: Array<new (...args: any[]) => Error> = [
ValidationException,
AuthenticationException,
AuthorizationException,
]
// Register exception handling callbacks
register() {
// Report to Sentry
this.reportable((error) => {
if (Sentry.isEnabled()) {
Sentry.captureException(error)
}
})
// Handle specific exception types
this.renderable((error, request) => {
if (error instanceof ModelNotFoundException) {
return response({
message: 'Resource not found',
}, 404)
}
})
// Log context
this.context(() => ({
userId: auth().id,
url: request().url,
}))
}
// Determine if exception should be reported
shouldReport(error: Error) {
// Don't report ignored exceptions
if (this.dontReport.some(type => error instanceof type)) {
return false
}
// Don't report client errors (4xx)
if (error instanceof HttpException && error.statusCode < 500) {
return false
}
return true
}
// Report exception to external services
async report(error: Error) {
// Log locally
Log.error(error.message, {
exception: error.name,
trace: error.stack,
context: this.context(),
})
// Report to Sentry
if (Sentry.isEnabled() && this.shouldReport(error)) {
Sentry.captureException(error, {
extra: this.context(),
})
}
// Notify on Slack for critical errors
if (error instanceof CriticalException) {
await this.notifySlack(error)
}
}
// Render exception into HTTP response
render(error: Error, request: Request) {
// API request - return JSON
if (request.wantsJson()) {
return this.renderJson(error)
}
// Web request - return HTML
return this.renderHtml(error)
}
protected renderJson(error: Error) {
const response: any = {
message: error.message,
}
if (error instanceof HttpException) {
response.status = error.statusCode
}
if (error instanceof ValidationException) {
response.errors = error.errors()
}
if (config('error-handling.debug')) {
response.exception = error.name
response.trace = error.stack?.split('\n')
}
return response(response, this.getStatusCode(error))
}
protected renderHtml(error: Error) {
const status = this.getStatusCode(error)
// Check for custom error view
const customView = config(`error-handling.render.views.${status}`)
if (customView && view.exists(customView)) {
return view(customView, { error, status })
}
// Default error view
return view('errors/generic', {
error,
status,
debug: config('error-handling.debug'),
})
}
protected getStatusCode(error: Error): number {
if (error instanceof HttpException) {
return error.statusCode
}
if (error instanceof ValidationException) {
return 422
}
if (error instanceof AuthenticationException) {
return 401
}
if (error instanceof AuthorizationException) {
return 403
}
return 500
}
}
Custom Exceptions
Creating Custom Exceptions
buddy make:exception PaymentFailedException
buddy make:exception RateLimitExceededException
buddy make:exception InsufficientCreditsException
// app/Exceptions/PaymentFailedException.ts
import { Exception } from '@stacksjs/error-handling'
export default class PaymentFailedException extends Exception {
// HTTP status code
statusCode = 402
// Error code for API clients
code = 'PAYMENT_FAILED'
constructor(
public reason: string,
public orderId: number,
) {
super(`Payment failed: ${reason}`)
}
// How to render this error
render(request: Request) {
if (request.wantsJson()) {
return response({
message: this.message,
code: this.code,
reason: this.reason,
order_id: this.orderId,
}, this.statusCode)
}
return view('errors/payment-failed', {
reason: this.reason,
orderId: this.orderId,
})
}
// How to report this error
report() {
Log.error('Payment failed', {
reason: this.reason,
orderId: this.orderId,
})
// Don't report to Sentry (expected error)
return false
}
// Additional context for logging
context() {
return {
reason: this.reason,
orderId: this.orderId,
}
}
}
// Usage
throw new PaymentFailedException('Card declined', order.id)
HTTP Exceptions
import { HttpException } from '@stacksjs/error-handling'
// Built-in HTTP exceptions
throw new HttpException(404, 'Page not found')
throw new HttpException(500, 'Server error')
// Convenience exceptions
throw new NotFoundHttpException('User not found')
throw new UnauthorizedHttpException('Invalid credentials')
throw new ForbiddenHttpException('Access denied')
throw new BadRequestHttpException('Invalid input')
throw new ConflictHttpException('Resource already exists')
throw new TooManyRequestsHttpException('Rate limit exceeded')
throw new ServiceUnavailableHttpException('Service temporarily unavailable')
Domain Exceptions
// app/Exceptions/Domain/InsufficientCreditsException.ts
export class InsufficientCreditsException extends Exception {
statusCode = 402
code = 'INSUFFICIENT_CREDITS'
constructor(
public required: number,
public available: number,
) {
super(`Insufficient credits: need ${required}, have ${available}`)
}
render() {
return response({
message: this.message,
code: this.code,
required: this.required,
available: this.available,
}, 402)
}
}
// Usage
if (user.credits < cost) {
throw new InsufficientCreditsException(cost, user.credits)
}
Error Pages
Custom Error Views
<!-- resources/views/errors/404.stx -->
<x-error-layout
<divclass="
<h1404</h1
<h2Page Not Found</h2
<pThe page you're looking for doesn't exist or has been moved.</p
<divclass="
<x-buttonhref="Go Home</x-button
<x-buttonvariant="onclick="Go Back</x-button
</div
</div
</x-error-layout
<!-- resources/views/errors/500.stx -->
<x-error-layout
<divclass="
<h1500</h1
<h2Server Error</h2
<pSomething went wrong on our end. We've been notified and are working on it.</p
@if(config'error-handling.debug')
<divclass="
<h3{{ error.name }}</h3
<p{{ error.message }}</p
<pre{{ error.stack }}</pre
</div
<divclass="
<x-buttonhref="Go Home</x-button
<x-buttonvariant="onclick="Try Again</x-button
</div
</div
</x-error-layout
<!-- resources/views/errors/maintenance.stx -->
<x-error-layout
<divclass="
<x-iconname="size="
<h1We'll be back soon!</h1
<pWe're performing scheduled maintenance. Please check back shortly.</p
@if($retryAfter
<pEstimated time: {{ retryAfter }} minutes</p
</div
</x-error-layout
Error Layout
<!-- resources/views/layouts/error.stx -->
<!DOCTYPE html>
<htmllang="
<head
<metacharset="
<metaname="content="
<title{{ status ?? 'Error' }} - {{ config('app.name') }}</title
<style>
body {
font-family: system-ui, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
}
.error-page {
text-align: center;
padding: 2rem;
}
.error-page h1 {
font-size: 6rem;
margin: 0;
color: #333;
}
.error-page h2 {
margin: 0 0 1rem;
color: #666;
}
.actions {
margin-top: 2rem;
display: flex;
gap: 1rem;
justify-content: center;
}
</style
</head
<body
<x-slotname="
</body
</html
Try-Catch Patterns
Basic Error Handling
// Controller level
export default class PaymentController extends Controller {
async charge(request: Request) {
try {
const payment = await PaymentGateway.charge(
request.input('amount'),
request.input('token'),
)
return response({ payment })
} catch (error) {
if (error instanceof CardDeclinedException) {
return response({
message: 'Your card was declined',
code: 'CARD_DECLINED',
}, 402)
}
if (error instanceof InsufficientFundsException) {
return response({
message: 'Insufficient funds',
code: 'INSUFFICIENT_FUNDS',
}, 402)
}
// Re-throw unexpected errors
throw error
}
}
}
Result Pattern
// app/Support/Result.ts
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E }
// Usage in services
class PaymentService {
async charge(amount: number, token: string): Promise<Result<Payment, PaymentError>> {
try {
const payment = await gateway.charge(amount, token)
return { success: true, data: payment }
} catch (error) {
return {
success: false,
error: new PaymentError(error.message, error.code),
}
}
}
}
// Controller
async charge(request: Request) {
const result = await paymentService.charge(
request.input('amount'),
request.input('token'),
)
if (!result.success) {
return response({
message: result.error.message,
code: result.error.code,
}, 402)
}
return response({ payment: result.data })
}
Rescue Pattern
import { rescue, retry } from '@stacksjs/error-handling'
// Rescue with default value
const config = await rescue(
() => loadExternalConfig(),
defaultConfig,
)
// Rescue with callback
const user = await rescue(
() => User.findOrFail(id),
(error) => {
Log.warning('User not found', { id, error })
return null
},
)
// Retry with backoff
const result = await retry(
() => externalApi.fetch(),
{
times: 3,
sleep: 1000,
when: (error) => error.isRetryable,
},
)
Logging Errors
Error Logging
import { Log } from '@stacksjs/logging'
try {
await riskyOperation()
} catch (error) {
// Log with context
Log.error('Operation failed', {
error: error.message,
stack: error.stack,
userId: user.id,
operation: 'riskyOperation',
})
// Log and re-throw
throw error
}
Error Context
// Add global context to all errors
Log.withContext({
requestId: request.id,
userId: auth().id,
url: request.url,
})
// Context is automatically included
Log.error('Something went wrong')
// Output includes requestId, userId, url
Sentry Integration
Setup Sentry
bun add @sentry/node
// app/Providers/ErrorServiceProvider.ts
import * as Sentry from '@sentry/node'
export default class ErrorServiceProvider extends ServiceProvider {
boot() {
if (config('error-handling.reporting.sentry.enabled')) {
Sentry.init({
dsn: config('error-handling.reporting.sentry.dsn'),
environment: config('app.env'),
tracesSampleRate: config('error-handling.reporting.sentry.tracesSampleRate'),
beforeSend(event, hint) {
// Filter or modify events
if (hint.originalException instanceof IgnoredException) {
return null
}
return event
},
})
}
}
}
Capture Errors with Context
import * as Sentry from '@sentry/node'
// Capture with additional context
Sentry.withScope((scope) => {
scope.setUser({ id: user.id, email: user.email })
scope.setTag('feature', 'payments')
scope.setExtra('order', order)
Sentry.captureException(error)
})
// Add breadcrumbs
Sentry.addBreadcrumb({
category: 'payment',
message: 'Payment attempt started',
level: 'info',
data: { amount, orderId },
})
Error Monitoring
Health Checks
// config/health.ts
export default {
checks: [
{
name: 'error-rate',
check: async () => {
const errors = await ErrorLog
.where('created_at', '>=', dayjs().subtract(5, 'minutes'))
.count()
return {
healthy: errors < 100,
errorCount: errors,
message: errors >= 100 ? 'High error rate detected' : 'OK',
}
},
},
],
}
Error Dashboard
// Dashboard widget for error monitoring
// Access at /dashboard/errors
import { ErrorMonitor } from '@stacksjs/error-handling'
// Get error stats
const stats = await ErrorMonitor.stats({
period: '24h',
})
// {
// total: 150,
// byType: { ValidationException: 100, ServerError: 50 },
// byEndpoint: { '/api/users': 80, '/api/orders': 70 },
// trend: 'decreasing',
// }
// Get recent errors
const recent = await ErrorMonitor.recent(20)
Testing Error Handling
import { expectException } from '@stacksjs/testing'
describe('Error Handling', () => {
it('throws validation exception', async () => {
await expectException(
() => User.create({ email: 'invalid' }),
ValidationException,
)
})
it('returns 404 for missing resource', async () => {
const response = await request.get('/api/users/99999')
expect(response.status).toBe(404)
expect(response.body.message).toBe('User not found')
})
it('returns validation errors', async () => {
const response = await request.post('/api/users', {
email: 'invalid',
})
expect(response.status).toBe(422)
expect(response.body.errors.email).toBeDefined()
})
it('handles unexpected errors gracefully', async () => {
// Force an error
jest.spyOn(UserService, 'create').mockRejectedValue(new Error('DB error'))
const response = await request.post('/api/users', validData)
expect(response.status).toBe(500)
expect(response.body.message).toBe('Internal server error')
// Stack trace should not be exposed
expect(response.body.stack).toBeUndefined()
})
})
Best Practices
Error Types
// Use specific exception types
throw new PaymentFailedException(reason) // Good
throw new Error('Payment failed') // Bad - too generic
// Include context
throw new InsufficientCreditsException(required, available) // Good
throw new Error('Not enough credits') // Bad - no context
Fail Fast
// Validate early, fail early
async createOrder(data: OrderData) {
// Validate first
if (!data.items.length) {
throw new EmptyCartException()
}
if (await this.isDuplicateOrder(data)) {
throw new DuplicateOrderException(data.idempotencyKey)
}
// Then proceed with business logic
return Order.create(data)
}
Graceful Degradation
// Handle partial failures gracefully
async fetchDashboard() {
const [users, orders, stats] = await Promise.allSettled([
User.count(),
Order.recent(10),
Analytics.summary(),
])
return {
users: users.status === 'fulfilled' ? users.value : null,
orders: orders.status === 'fulfilled' ? orders.value : [],
stats: stats.status === 'fulfilled' ? stats.value : {},
warnings: [users, orders, stats]
.filter(r => r.status === 'rejected')
.map(r => r.reason.message),
}
}