error-handling · diff
git:20260121.ca4bb92 to git:20260217.1e6769f
1 added, 1 removed. Audit A to A.
---
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-architecture.md`
+ **Reference**: `.cursor/adr/0015-error-handling.md`