git:20260217.1e6769f to git:20260309.80c3cb2

1 added, 203 removed. Audit A to B.

---
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`
+ @docs/error-handling.md