error-handling · git:20260217.1e6769f · 2026-02-17 · sha256 f748bd891fb5567a

error-handling git:20260217.1e6769fA

Immutable. This exact content is served forever at /api/v1/blob/f748bd891fb5567a.

---
description: Error handling conventions using AppError across all layers
globs: src/core/**/*
alwaysApply: false
---

# Error Handling Rules

Rules for consistent error handling across all layers. Based on ADR-0015.

## Core Principle

**Every layer uses `AppError`** with category-based typing. No raw `Error`, no plain strings.

```typescript
// ✅ Good - Use Err.* factories
throw Err.database(DatabaseErrorCode.WRITE_FAILED, 'Failed to save post', {
  service: ErrorService.Local,
  operation: 'create',
  context: { table: 'posts', id: post.id },
  cause: error,
});

// ❌ Bad
throw new Error('Failed to save');
throw 'Something went wrong';
```

## Error Taxonomy

**Categories** (WHAT kind of failure): `Network`, `Timeout`, `Server`, `Client`, `Auth`, `RateLimit`, `Validation`, `Database`

**Codes** (WHICH specific error): `WRITE_FAILED`, `NOT_FOUND`, `UNAUTHORIZED`, `SESSION_EXPIRED`, etc.

**Services** (WHERE it originated): `Nexus`, `Homeserver`, `Homegate`, `Local`, etc.

## Layer-Specific Patterns

### Services / Models (`src/core/services/`)

```typescript
// Use Err.* factories
// Include service, operation, context, cause
// NOTE (current implementation): Err.* factories log automatically.
// Avoid logging the same failure again in the calling layer unless you are intentionally adding *new* context
// (and understand this may duplicate logs until Phase 2 de-dup is implemented).

class LocalPostService {
  static async create({ compositePostId, post }: TLocalSavePostParams) {
    try {
      await Core.db.transaction('rw', [...tables], async () => {
        // ... database operations
      });
    } catch (error) {
      throw Err.database(DatabaseErrorCode.WRITE_FAILED, 'Failed to save post', {
        service: ErrorService.Local,
        operation: 'create',
        context: { compositePostId, kind: post.kind },
        cause: error,
      });
    }
  }
}
```

### Remote Services (`src/core/services/nexus/`, `homegate/`, `homeserver/`)

```typescript
// Use safeFetch + httpResponseToError
// Or use queryNexus which wraps both with TanStack Query retry

class HomegateService {
  static async verifySmsCode(phoneNumber: string, code: string) {
    const url = homegateApi.validateSmsCode();
    
    const response = await safeFetch(
      url,
      { method: HttpMethod.POST, body: JSON.stringify({ phoneNumber, code }), headers: JSON_HEADERS },
      ErrorService.Homegate,
      'verifySmsCode',
    );

    if (!response.ok) {
      throw httpResponseToError(response, ErrorService.Homegate, 'verifySmsCode', url);
    }

    return await parseResponseOrThrow<TResult>(response, ErrorService.Homegate, 'verifySmsCode', url);
  }
}
```

### Application Layer (`src/core/application/`)

```typescript
// Use decision helpers: isRetryable(), requiresLogin(), isNotFound()
// Re-throw or handle based on category/code
// NOTE (current implementation): logging happens in Err.* factories, so Application code should usually *not* log again.

class PostApplication {
  static async create(post: Post) {
    try {
      await LocalPostService.create(post);
    } catch (error) {
      // Prefer the AppError contract, normalize only if it's truly unknown.
      const appError = toAppError(error, ErrorService.Local, 'create');

      if (isNotFound(appError)) {
        // Handle specific case
      }
      throw appError; // Let it bubble to UI
    }
  }
}
```

### UI Layer (`src/components/`, `src/hooks/`)

```typescript
// Convert AppError to user-facing responses
// Use requiresLogin() for auth redirects
// Use isNotFound() for empty states

try {
  await PostApplication.create(post);
} catch (error) {
  if (error instanceof AppError) {
    if (requiresLogin(error)) {
      router.push('/login');
      return;
    }
    toast.error(getErrorMessage(error));
  }
}
```

## Error Utilities

```typescript
// Category predicates
isNetworkError(error)   // Network category
isServerError(error)    // Server category  
isAuthError(error)      // Auth category
isDatabaseError(error)  // Database category

// Decision helpers
isRetryable(error)      // Network, Timeout, Server, RateLimit → true
requiresLogin(error)    // Auth + UNAUTHORIZED or SESSION_EXPIRED → true
isNotFound(error)       // NOT_FOUND or RECORD_NOT_FOUND → true
getRetryAfter(error)    // Extract retry delay from context

// Normalization
toAppError(error, service, operation)  // Wrap unknown errors
getErrorMessage(error)                  // Extract user message
```

## Re-throw Discipline (avoid double logging)

Because `Err.*` factories **log automatically today**, follow these rules when catching:

- **If you caught an `AppError`**: re-throw it unchanged (`throw error`). Do **not** call `Err.*` again for the same failure.
- **If you caught an unknown error**: normalize once with `toAppError(error, service, operation)` and throw that.
- **If you truly need additional context**: prefer adding it at the origin (service/model) where the error is created, not by re-wrapping higher up.

## Logging Discipline

**Log once (current implementation: in `Err.*` factories):**

```typescript
// ❌ Bad: Logging in catch + throwing Err.* (double logs today)
class LocalPostService {
  static async create(post) {
    try { ... } catch (error) {
      Logger.error('Failed', error);  // DON'T log here
      throw Err.database(...);
    }
  }
}

// ✅ Good: Just throw Err.* (factories log automatically)
class PostApplication {
  static async create(post) {
    try { ... } catch (error) {
      // Pass-through AppError unchanged; normalize unknowns once.
      throw toAppError(error, ErrorService.Local, 'createPost');
    }
  }
}
```

## TanStack Query Integration

The QueryClient reads `error.context.statusCode` for retry decisions. Configure per-service:

- **Retryable**: Network, Timeout, Server, RateLimit categories
- **Non-retryable**: Client, Auth, Validation, Database categories
- Each service can mark specific codes as non-retryable

## Quick Checklist

When handling errors:

- [ ] Using `Err.*` factories (not raw Error)?
- [ ] Including `service`, `operation`, `context`, `cause`?
- [ ] Avoiding duplicate logs (don’t `Logger.error` and then throw `Err.*` for the same failure)?
- [ ] Using `safeFetch` for HTTP requests?
- [ ] Checking `category`/`code` instead of parsing messages?
- [ ] Using decision helpers (`isRetryable`, `requiresLogin`, `isNotFound`)?

---

**Reference**: `.cursor/adr/0015-error-handling.md`