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`