> ## Documentation Index
> Fetch the complete documentation index at: https://docs.theroyalglow.in/llms.txt
> Use this file to discover all available pages before exploring further.

# Error Handling

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

# Error Handling

<Info>
  **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.
</Info>

## 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

<Tabs>
  <Tab title="Error">
    ```json theme={null}
    {
      "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
      }
    }
    ```
  </Tab>

  <Tab title="Success">
    ```json theme={null}
    {
      "success": true,
      "data": { ... },
      "meta": { "page": 1, "totalPages": 5, "totalCount": 47 }
    }
    ```
  </Tab>
</Tabs>

## HTTP Status Code Map

| Code  | Error Code                   | When Used                                |
| ----- | ---------------------------- | ---------------------------------------- |
| `400` | `VALIDATION_ERROR`           | Zod validation fails, malformed JSON     |
| `401` | `UNAUTHENTICATED`            | No session, expired session              |
| `403` | `FORBIDDEN`                  | Authenticated but lacks permission       |
| `404` | `NOT_FOUND`                  | Resource doesn't exist                   |
| `409` | `CONFLICT` / domain-specific | Slot taken, duplicate booking            |
| `422` | `BUSINESS_RULE_VIOLATION`    | Valid data but violates business rule    |
| `429` | `RATE_LIMITED`               | Upstash Ratelimit threshold exceeded     |
| `500` | `INTERNAL_ERROR`             | Unhandled exception, DB failure          |
| `502` | `UPSTREAM_ERROR`             | Resend API down, PDF service unreachable |
| `503` | `SERVICE_UNAVAILABLE`        | Neon DB in maintenance                   |

## Error Classification

<Tabs>
  <Tab title="Operational (expected)">
    Logged at `warn` level. No Sentry alert.

    | Category       | Examples                                                         |
    | -------------- | ---------------------------------------------------------------- |
    | Validation     | Invalid phone, missing required field, bad date format           |
    | Auth           | Expired session, insufficient role                               |
    | Business rules | Slot already booked, membership expired, max reschedules reached |
    | Rate limits    | Too many API calls                                               |
    | Not found      | Booking ID doesn't exist                                         |
  </Tab>

  <Tab title="Programmer (unexpected)">
    Logged at `error` level. Sentry captures with full context.

    | Category                  | Examples                                        |
    | ------------------------- | ----------------------------------------------- |
    | Unhandled exceptions      | TypeError, null reference, missing env var      |
    | DB errors                 | Connection pool exhausted, constraint violation |
    | External service failures | Resend 5xx, Ably connection lost, Neon timeout  |
  </Tab>
</Tabs>

## Error Code Registry

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

```ts theme={null}
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

```ts theme={null}
// 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`:

```ts theme={null}
// 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:

```ts theme={null}
// 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:

```ts theme={null}
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 Type                         | Sentry Level | Alert                     |
| ---------------------------------- | ------------ | ------------------------- |
| Unhandled exception (500)          | `error`      | Slack + Email immediately |
| External service failure (502/503) | `warning`    | Slack 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.

| Endpoint                | Window | Limit |
| ----------------------- | ------ | ----- |
| `POST /api/bookings`    | 1 min  | 5     |
| `POST /api/leads`       | 1 min  | 3     |
| `GET /api/availability` | 10s    | 10    |
| `GET /api/services`     | 10s    | 20    |
| `GET /api/health`       | 10s    | 30    |

## Client-Side Error Handling

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

<AccordionGroup>
  <Accordion title="UNAUTHENTICATED → redirect to sign-in">
    The session is missing or expired — send the user to the sign-in page.
  </Accordion>

  <Accordion title="RATE_LIMITED → show a wait toast">
    Show a toast: "Too many requests. Please wait."
  </Accordion>

  <Accordion title="VALIDATION_ERROR → let the form handle it">
    Let the form surface field-level errors from `details`.
  </Accordion>

  <Accordion title="Anything else → show error.message in a toast">
    Fall back to displaying `error.message` in a toast.
  </Accordion>
</AccordionGroup>

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.


## Related topics

- [Conventions](/content/docs/conventions.md)
- [Security](/content/docs/security.md)
- [Overview](/content/docs/index.md)
- [Favourite Services](/content/docs/features/favourite-services.md)
- [Staff Scheduling & Leave](/content/docs/features/scheduling-leave.md)
