mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
Run prettier --write to fix formatting inconsistencies in 80 files across calendar, contacts, picture, presi, storage, zitare apps and shared packages/documentation.
14 KiB
14 KiB
Error Handling Guidelines
Philosophy: Go-Style Error Handling
We use explicit error handling inspired by Go's error handling pattern. Instead of throwing exceptions everywhere, we return Result<T> types that force callers to handle errors explicitly.
Why?
- Explicit over implicit - Errors are part of the function signature
- No surprise exceptions - You know exactly what can fail
- Consistent error codes - Same codes across frontend and backend
- Better error messages - Structured errors with codes and context
Package: @manacore/shared-errors
The error handling system is implemented in packages/shared-errors/. Import from it:
import {
// Result type and helpers
Result,
AsyncResult,
ok,
err,
isOk,
isErr,
unwrap,
unwrapOr,
map,
andThen,
match,
tryCatch,
tryCatchAsync,
combine,
// Error codes
ErrorCode,
ERROR_CODE_TO_HTTP_STATUS,
// Error classes
AppError,
ValidationError,
NotFoundError,
AuthError,
CreditError,
ServiceError,
RateLimitError,
NetworkError,
DatabaseError,
// Type guards
isAppError,
isValidationError,
isNotFoundError,
hasErrorCode,
isRetryable,
getHttpStatus,
// Utilities
wrap,
toAppError,
} from '@manacore/shared-errors';
Core Types
Result Type
// Result represents success or failure
export type Result<T, E extends AppError = AppError> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
// Async version for async functions
export type AsyncResult<T, E extends AppError = AppError> = Promise<Result<T, E>>;
// Create success result
const user = ok({ id: '123', name: 'John' });
// Create failure result
const error = err(new NotFoundError('User', userId));
Error Classes
// Base error class
class AppError extends Error {
code: ErrorCode;
context?: ErrorContext;
cause?: Error;
}
// Specialized error classes
ValidationError.invalidInput('email', 'must be valid email');
NotFoundError.user(userId);
AuthError.tokenExpired();
CreditError.insufficient(required, available);
ServiceError.generation('AI generation failed');
RateLimitError.exceeded(retryAfter);
NetworkError.timeout();
DatabaseError.constraint('unique_email');
Error Codes
All error codes are defined in @manacore/shared-errors:
export enum ErrorCode {
// Validation (400)
VALIDATION_FAILED = 'VALIDATION_FAILED',
INVALID_INPUT = 'INVALID_INPUT',
MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD',
INVALID_FORMAT = 'INVALID_FORMAT',
// Authentication (401)
AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED',
INVALID_TOKEN = 'INVALID_TOKEN',
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
// Authorization (403)
PERMISSION_DENIED = 'PERMISSION_DENIED',
RESOURCE_NOT_OWNED = 'RESOURCE_NOT_OWNED',
// Not Found (404)
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
USER_NOT_FOUND = 'USER_NOT_FOUND',
// Payment/Credit (402)
INSUFFICIENT_CREDITS = 'INSUFFICIENT_CREDITS',
PAYMENT_REQUIRED = 'PAYMENT_REQUIRED',
// Conflict (409)
CONFLICT = 'CONFLICT',
DUPLICATE_ENTRY = 'DUPLICATE_ENTRY',
// Rate Limiting (429)
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
// Service Errors (500)
INTERNAL_ERROR = 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
GENERATION_FAILED = 'GENERATION_FAILED',
EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR',
// Network Errors
NETWORK_ERROR = 'NETWORK_ERROR',
TIMEOUT = 'TIMEOUT',
// Database Errors
DATABASE_ERROR = 'DATABASE_ERROR',
CONSTRAINT_VIOLATION = 'CONSTRAINT_VIOLATION',
// Unknown
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
}
HTTP Status Mapping
import { ERROR_CODE_TO_HTTP_STATUS, getHttpStatus } from '@manacore/shared-errors';
// Get HTTP status for an error code
const status = ERROR_CODE_TO_HTTP_STATUS[ErrorCode.RESOURCE_NOT_FOUND]; // 404
// Or use helper function
const status = getHttpStatus(error); // Returns appropriate HTTP status
Retryable Errors
import { isRetryable } from '@manacore/shared-errors';
// Check if an error is worth retrying
if (isRetryable(error)) {
await delay(1000);
return retry(operation);
}
Backend Usage
Service Layer
// src/files/file.service.ts
import { Injectable, Inject } from '@nestjs/common';
import {
AsyncResult,
ok,
err,
isOk,
NotFoundError,
DatabaseError,
ValidationError,
} from '@manacore/shared-errors';
import { DATABASE_CONNECTION } from '../db/database.module';
import { files, File, NewFile } from '../db/schema';
@Injectable()
export class FileService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findById(id: string, userId: string): AsyncResult<File> {
try {
const [file] = await this.db
.select()
.from(files)
.where(and(eq(files.id, id), eq(files.userId, userId), eq(files.isDeleted, false)));
if (!file) {
return err(new NotFoundError('File', id));
}
return ok(file);
} catch (error) {
return err(DatabaseError.query('Failed to fetch file', error));
}
}
async create(userId: string, dto: CreateFileDto): AsyncResult<File> {
// Validation
if (!dto.name?.trim()) {
return err(ValidationError.required('name'));
}
try {
const newFile: NewFile = {
userId,
name: dto.name.trim(),
mimeType: dto.mimeType,
size: dto.size,
storagePath: dto.storagePath,
storageKey: dto.storageKey,
parentFolderId: dto.folderId,
};
const [created] = await this.db.insert(files).values(newFile).returning();
return ok(created);
} catch (error) {
// Handle unique constraint violations
if (error.code === '23505') {
return err(DatabaseError.constraint('A file with this name already exists'));
}
return err(DatabaseError.query('Failed to create file', error));
}
}
async delete(id: string, userId: string): AsyncResult<void> {
const fileResult = await this.findById(id, userId);
if (!isOk(fileResult)) {
return fileResult; // Propagate error
}
try {
await this.db
.update(files)
.set({ isDeleted: true, deletedAt: new Date() })
.where(eq(files.id, id));
return ok(undefined);
} catch (error) {
return err(DatabaseError.query('Failed to delete file', error));
}
}
}
Controller Layer
// src/files/file.controller.ts
import { Controller, Get, Post, Delete, Param, Body, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { isOk, unwrap } from '@manacore/shared-errors';
import { FileService } from './file.service';
@Controller('files')
@UseGuards(JwtAuthGuard)
export class FileController {
constructor(private readonly fileService: FileService) {}
@Get(':id')
async getFile(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
const result = await this.fileService.findById(id, user.userId);
if (!isOk(result)) {
throw result.error; // AppError extends Error, caught by exception filter
}
return { file: result.value };
}
@Post()
async createFile(@Body() dto: CreateFileDto, @CurrentUser() user: CurrentUserData) {
const result = await this.fileService.create(user.userId, dto);
if (!isOk(result)) {
throw result.error;
}
return { file: result.value };
}
@Delete(':id')
async deleteFile(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
// Alternative: use unwrap() which throws on error
unwrap(await this.fileService.delete(id, user.userId));
return { success: true };
}
}
Exception Filter
The package provides a ready-to-use exception filter:
// In main.ts or app.module.ts
import { AppExceptionFilter } from '@manacore/shared-errors/nestjs';
// Apply globally
app.useGlobalFilters(new AppExceptionFilter());
The filter automatically:
- Maps
ErrorCodeto HTTP status codes - Returns consistent JSON error format
- Logs server errors (5xx)
Custom filter example:
// src/common/filters/app-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger } from '@nestjs/common';
import { Response } from 'express';
import { AppError, isAppError, getHttpStatus } from '@manacore/shared-errors';
@Catch(AppError)
export class AppExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger('AppException');
catch(exception: AppError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = getHttpStatus(exception);
// Log server errors
if (status >= 500) {
this.logger.error(exception.message, exception.stack);
}
response.status(status).json({
ok: false,
error: {
code: exception.code,
message: exception.message,
},
});
}
}
Frontend Usage
API Client
// lib/api/client.ts
import { Result, err, ErrorCode, AppError } from '@manacore/shared-errors';
interface ApiResponse<T> {
ok: boolean;
data?: T;
error?: AppError;
}
async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<Result<T>> {
try {
const token = await getAuthToken();
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
const json: ApiResponse<T> = await response.json();
if (!json.ok || json.error) {
return {
ok: false,
error: json.error ?? {
code: ErrorCode.UNKNOWN_ERROR,
message: 'Request failed',
},
};
}
return { ok: true, data: json.data as T };
} catch (error) {
return err(ErrorCode.EXTERNAL_SERVICE_ERROR, 'Network request failed');
}
}
// Typed API methods
export const api = {
files: {
get: (id: string) => apiRequest<File>(`/files/${id}`),
list: (folderId?: string) => apiRequest<File[]>(`/files?folderId=${folderId ?? ''}`),
create: (data: CreateFileDto) =>
apiRequest<File>('/files', {
method: 'POST',
body: JSON.stringify(data),
}),
delete: (id: string) => apiRequest<void>(`/files/${id}`, { method: 'DELETE' }),
},
};
Component Usage (Svelte 5)
<script lang="ts">
import { api } from '$lib/api/client';
import { ErrorCode } from '@manacore/shared-errors';
let files = $state<File[]>([]);
let error = $state<string | null>(null);
let loading = $state(false);
async function loadFiles() {
loading = true;
error = null;
const result = await api.files.list();
if (!result.ok) {
// Handle specific error codes
switch (result.error.code) {
case ErrorCode.UNAUTHORIZED:
goto('/login');
break;
case ErrorCode.FORBIDDEN:
error = 'You do not have permission to view these files';
break;
default:
error = result.error.message;
}
} else {
files = result.data;
}
loading = false;
}
async function deleteFile(id: string) {
const result = await api.files.delete(id);
if (!result.ok) {
showToast({ type: 'error', message: result.error.message });
return;
}
files = files.filter((f) => f.id !== id);
showToast({ type: 'success', message: 'File deleted' });
}
</script>
Component Usage (React Native)
// hooks/useFiles.ts
import { useState, useCallback } from 'react';
import { api } from '../services/api';
import { ErrorCode, Result, AppError } from '@manacore/shared-errors';
export function useFiles() {
const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<AppError | null>(null);
const loadFiles = useCallback(async () => {
setLoading(true);
setError(null);
const result = await api.files.list();
if (!result.ok) {
setError(result.error);
} else {
setFiles(result.data);
}
setLoading(false);
}, []);
const deleteFile = useCallback(async (id: string): Promise<boolean> => {
const result = await api.files.delete(id);
if (!result.ok) {
return false;
}
setFiles((prev) => prev.filter((f) => f.id !== id));
return true;
}, []);
return { files, loading, error, loadFiles, deleteFile };
}
Error Chaining
Wrapping Errors with Context
async function processUpload(userId: string, file: File): Promise<Result<FileRecord>> {
// Validate file
const validationResult = validateFile(file);
if (!validationResult.ok) {
return validationResult; // Return validation error as-is
}
// Upload to storage
const uploadResult = await storageService.upload(file);
if (!uploadResult.ok) {
// Add context to storage error
return err(ErrorCode.UPLOAD_FAILED, `Failed to upload file: ${uploadResult.error.message}`, {
originalError: uploadResult.error,
});
}
// Save to database
const saveResult = await fileService.create(userId, {
name: file.name,
storagePath: uploadResult.data.path,
});
if (!saveResult.ok) {
// Cleanup on failure
await storageService.delete(uploadResult.data.path);
return saveResult;
}
return saveResult;
}
Logging Errors
import { Logger } from '@nestjs/common';
@Injectable()
export class FileService {
private readonly logger = new Logger(FileService.name);
async create(userId: string, dto: CreateFileDto): Promise<Result<File>> {
try {
// ... operation
} catch (error) {
// Log full error for debugging
this.logger.error('Failed to create file', {
userId,
fileName: dto.name,
error: error.message,
stack: error.stack,
});
// Return user-friendly error
return err(ErrorCode.DATABASE_ERROR, 'Failed to create file');
}
}
}
Best Practices
Do's
- Always check result.ok before accessing data
- Use specific error codes rather than generic ones
- Include helpful messages for debugging
- Log errors at the service layer
- Return early on errors to avoid nested conditions
Don'ts
- Don't throw exceptions in services - use Result instead
- Don't expose internal error details to users
- Don't use try-catch for flow control
- Don't ignore error results - always handle them
- Don't use string error codes - use the ErrorCode enum