error-handling · git:20260103.9f62587 · 2026-01-03 · sha256 f7668def1c378c03

error-handling git:20260103.9f62587A

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

---
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 error-handling.md.

## Core Principle

**Every layer uses `AppError`** - no raw `Error`, no plain strings.

```typescript
// ✅ Good
throw createDatabaseError(DatabaseErrorType.UPSERT_FAILED, { table, id });

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

## Layer-Specific Patterns

### Models (`src/core/*/models/`)

```typescript
// Always use createDatabaseError
// Include table and id in details
// Skip logging - let services decide

class PostModel {
  static async upsert(post: Post) {
    try {
      await db.posts.put(post);
    } catch (error) {
      throw createDatabaseError(DatabaseErrorType.UPSERT_FAILED, {
        table: 'posts',
        id: post.id,
        originalError: error,
      });
    }
  }
}
```

### Local Services (`src/core/*/services/local/`)

```typescript
// Catch model errors, add context
// Re-throw same AppError (preserve stack)
// Add service and action to details

class LocalPostService {
  static async create(post: Post) {
    try {
      await PostModel.upsert(post);
    } catch (error) {
      if (error instanceof AppError) {
        error.details = {
          ...error.details,
          service: 'LocalPostService',
          action: 'create',
        };
        throw error;
      }
      throw createCommonError(CommonErrorType.UNEXPECTED_ERROR, {
        service: 'LocalPostService',
        action: 'create',
        originalError: error,
      });
    }
  }
}
```

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

```typescript
// Use ensureHttpResponseOk and parseResponseOrThrow
// Include endpoint, method, status, bodyPreview
// Differentiate transient vs fatal errors

class NexusPostService {
  static async fetch(id: string) {
    const response = await queryNexus(`/v0/post/${id}`);
    
    // queryNexus already wraps errors in AppError
    // with status codes and endpoint info
    
    return response;
  }
}

// Transient errors (retryable):
// - SERVICE_UNAVAILABLE (503)
// - NETWORK_ERROR
// - TIMEOUT

// Fatal errors (don't retry):
// - NOT_FOUND (404)
// - UNAUTHORIZED (401)
// - BAD_REQUEST (400)
```

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

```typescript
// Treat AppError as canonical
// Map to domain errors if needed
// Wrap stray errors in CommonError
// Check error.type for retry decisions

class PostApplication {
  static async create(post: Post) {
    try {
      await LocalPostService.create(post);
      await this.syncToHomeserver(post);
    } catch (error) {
      if (error instanceof AppError) {
        // Map to domain error if needed
        if (error.statusCode === 404) {
          throw createSanitizationError(
            SanitizationErrorType.POST_NOT_FOUND,
            { postId: post.id }
          );
        }
        throw error;
      }
      // Wrap unexpected errors
      throw createCommonError(CommonErrorType.UNEXPECTED_ERROR, {
        context: 'PostApplication.create',
        originalError: error,
      });
    }
  }
}
```

### Controllers (`src/core/controllers/`)

```typescript
// Normalize any non-AppError at entry point
// Convert AppError to UI responses
// Use ErrorMessages for user-facing copy
// Avoid logging (delegate to reporter)

class PostController {
  static async commitCreatePost(input: CreatePostInput) {
    try {
      const post = PostPipe.normalizeCreate(input);
      await PostApplication.create(post);
      return { success: true };
    } catch (error) {
      const appError = error instanceof AppError 
        ? error 
        : createCommonError(CommonErrorType.UNEXPECTED_ERROR, { error });
      
      return {
        success: false,
        error: ErrorMessages[appError.type] || 'An error occurred',
        type: appError.type,
      };
    }
  }
}
```

## Error Types

### Database Errors
```typescript
DatabaseErrorType.UPSERT_FAILED
DatabaseErrorType.DELETE_FAILED
DatabaseErrorType.QUERY_FAILED
DatabaseErrorType.TRANSACTION_FAILED
```

### Common Errors
```typescript
CommonErrorType.UNEXPECTED_ERROR
CommonErrorType.INVALID_INPUT
CommonErrorType.NOT_FOUND
CommonErrorType.NETWORK_ERROR
CommonErrorType.TIMEOUT
```

### Domain Errors
```typescript
SanitizationErrorType.POST_NOT_FOUND
SanitizationErrorType.USER_NOT_FOUND
AuthErrorType.UNAUTHORIZED
AuthErrorType.SESSION_EXPIRED
```

## Logging Discipline

**Log once, at the right layer:**

```typescript
// ❌ Bad: Multiple layers logging same error
// Models: console.error(error)
// Services: console.error(error)
// Application: console.error(error)
// Controller: console.error(error)

// ✅ Good: Log in error factory or single handler
function createDatabaseError(type, details) {
  const error = new AppError(type, details);
  Logger.error({ type, details, stack: error.stack }); // Log once
  return error;
}
```

## Retry Guidance

```typescript
// Helper to check if error is retryable
function isTransient(error: AppError): boolean {
  return [
    CommonErrorType.SERVICE_UNAVAILABLE,
    CommonErrorType.NETWORK_ERROR,
    CommonErrorType.TIMEOUT,
  ].includes(error.type);
}

// Usage in application layer
async function fetchWithRetry(fn, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (error) {
      if (!isTransient(error) || i === maxRetries - 1) {
        throw error;
      }
      await delay(Math.pow(2, i) * 1000); // Exponential backoff
    }
  }
}
```

## Quick Checklist

When handling errors:

- [ ] Using `AppError` (not raw Error or strings)?
- [ ] Including relevant context in `details`?
- [ ] Layer-specific error types used?
- [ ] Logging only once (in factory or handler)?
- [ ] Transient vs fatal errors distinguished for retries?
- [ ] User-facing messages via ErrorMessages enum?

---

**Reference**: `.cursor/docs/error-handling.md`