Royal Glow internal docs · now fully interactive — Steps, API tables, file trees & live status
Royal Glow Docs

Error Handling

API error patterns, error codes, AppError class, and Sentry integration for Royal Glow.

Error Handling

In one line: Every error follows one structured JSON shape with a machine-readable code, a human message, and a requestId for Sentry correlation. Operational (expected) errors are logged at warn; programmer (unexpected) errors are logged at error and alert via Sentry.

Design Principles

  1. Never leak internals — No stack traces, DB column names, or internal IDs in client responses
  2. Structured, predictable responses — Every error follows the same JSON shape
  3. Machine-readable codes — Clients can programmatically handle specific errors
  4. Human-readable messages — Users see meaningful text, not "Something went wrong"
  5. Correlation everywhere — Every error carries a requestId for Sentry cross-referencing
  6. Fail gracefully — Operational errors return proper HTTP codes; programmer errors trigger alerts

Standard Response Shapes

{
  "success": false,
  "error": {
    "code": "BOOKING_SLOT_UNAVAILABLE",
    "message": "This time slot is no longer available. Please choose another.",
    "statusCode": 409,
    "requestId": "req_m8n3k5p2w",
    "retryable": false
  }
}
{
  "success": true,
  "data": { ... },
  "meta": { "page": 1, "totalPages": 5, "totalCount": 47 }
}

HTTP Status Code Map

CodeError CodeWhen Used
400VALIDATION_ERRORZod validation fails, malformed JSON
401UNAUTHENTICATEDNo session, expired session
403FORBIDDENAuthenticated but lacks permission
404NOT_FOUNDResource doesn't exist
409CONFLICT / domain-specificSlot taken, duplicate booking
422BUSINESS_RULE_VIOLATIONValid data but violates business rule
429RATE_LIMITEDUpstash Ratelimit threshold exceeded
500INTERNAL_ERRORUnhandled exception, DB failure
502UPSTREAM_ERRORResend API down, PDF service unreachable
503SERVICE_UNAVAILABLENeon DB in maintenance

Error Classification

Logged at warn level. No Sentry alert.

CategoryExamples
ValidationInvalid phone, missing required field, bad date format
AuthExpired session, insufficient role
Business rulesSlot already booked, membership expired, max reschedules reached
Rate limitsToo many API calls
Not foundBooking ID doesn't exist

Logged at error level. Sentry captures with full context.

CategoryExamples
Unhandled exceptionsTypeError, null reference, missing env var
DB errorsConnection pool exhausted, constraint violation
External service failuresResend 5xx, Ably connection lost, Neon timeout

Error Code Registry

All error codes are centralised in packages/errors/codes.ts — no magic strings:

export const ErrorCode = {
  // Generic
  VALIDATION_ERROR: 'VALIDATION_ERROR',
  INTERNAL_ERROR: 'INTERNAL_ERROR',
  UNAUTHENTICATED: 'UNAUTHENTICATED',
  FORBIDDEN: 'FORBIDDEN',
  NOT_FOUND: 'NOT_FOUND',
  RATE_LIMITED: 'RATE_LIMITED',
  UPSTREAM_ERROR: 'UPSTREAM_ERROR',

  // Booking domain
  BOOKING_SLOT_UNAVAILABLE: 'BOOKING_SLOT_UNAVAILABLE',
  BOOKING_ALREADY_CANCELLED: 'BOOKING_ALREADY_CANCELLED',
  BOOKING_MAX_RESCHEDULES: 'BOOKING_MAX_RESCHEDULES',
  BOOKING_CANCEL_WINDOW_PASSED: 'BOOKING_CANCEL_WINDOW_PASSED',

  // Membership domain
  MEMBERSHIP_EXPIRED: 'MEMBERSHIP_EXPIRED',
  MEMBERSHIP_INSUFFICIENT_HOURS: 'MEMBERSHIP_INSUFFICIENT_HOURS',
  MEMBERSHIP_ALREADY_ACTIVE: 'MEMBERSHIP_ALREADY_ACTIVE',

  // Gems domain
  GEMS_INSUFFICIENT_BALANCE: 'GEMS_INSUFFICIENT_BALANCE',
  GEMS_SERVICE_NOT_REDEEMABLE: 'GEMS_SERVICE_NOT_REDEEMABLE',

  // Offer domain
  OFFER_EXPIRED: 'OFFER_EXPIRED',
  OFFER_NOT_APPLICABLE: 'OFFER_NOT_APPLICABLE',
} as const

AppError Class

// packages/errors/app-error.ts

export class AppError extends Error {
  readonly code: ErrorCode
  readonly statusCode: number
  readonly isOperational: boolean
  readonly retryable: boolean
  readonly details?: unknown

  constructor(params: {
    code: ErrorCode
    message: string
    statusCode: number
    isOperational?: boolean  // default: true
    retryable?: boolean      // default: false
    details?: unknown
    cause?: Error
  }) { ... }
}

// Convenience factories
export function notFound(message = 'Resource not found') { ... }
export function forbidden(message = '...') { ... }
export function conflict(code: ErrorCode, message: string) { ... }
export function badRequest(message: string, details?: unknown) { ... }
export function serviceUnavailable(service: string, cause?: Error) { ... }

API Route Pattern

All API routes are wrapped with withErrorHandler:

// apps/web/src/app/api/bookings/route.ts
export const POST = withErrorHandler(async (req: Request) => {
  const body = await req.json()
  const parsed = createBookingSchema.safeParse(body)

  if (!parsed.success) {
    throw new AppError({
      code: 'VALIDATION_ERROR',
      message: 'Invalid request data',
      statusCode: 400,
      details: parsed.error.flatten().fieldErrors,
    })
  }

  const result = await createBooking(parsed.data)
  return Response.json({ success: true, data: result }, { status: 201 })
})

Business Layer Pattern

Business logic throws AppError — never catches it. The handler layer above decides what to do:

// packages/business/booking/create.ts
export async function createBooking(input: CreateBookingInput) {
  const isAvailable = await checkSlotAvailable(input.branchId, input.date, input.time)
  if (!isAvailable) {
    throw conflict('BOOKING_SLOT_UNAVAILABLE', 'This time slot is no longer available.')
  }
  // ...
}

Request ID Correlation

Every request gets a unique ID attached in middleware:

const requestId = `req_${nanoid(12)}`
requestHeaders.set('x-request-id', requestId)
response.headers.set('x-request-id', requestId)

This ID appears in every error response, every Sentry event, and every log line — making it trivial to trace a specific request across all systems.

Sentry Integration

Error TypeSentry LevelAlert
Unhandled exception (500)errorSlack + Email immediately
External service failure (502/503)warningSlack if >3 in 5 min
Business rule violation (4xx)Not sent
Validation error (400)Not sent
Rate limit exceeded (429)Not sent

Rate Limiting

All rate limiting runs in Next.js middleware via @upstash/ratelimit backed by Upstash Redis. Applied before auth checks or any business logic.

EndpointWindowLimit
POST /api/bookings1 min5
POST /api/leads1 min3
GET /api/availability10s10
GET /api/services10s20
GET /api/health10s30

Client-Side Error Handling

The client switches on the error code to decide what the user sees:

React Error Boundaries catch rendering errors at the route level. Each route group has its own error.tsx that reports to Sentry and shows a retry button.

OpenReport an issue

Was this page helpful?

On this page