mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-30 07:39:06 +08:00
refactor(api): consolidate error handling and update API error structures
- Replaced `DataApiError` with `SerializedDataApiError` for improved error serialization and IPC transmission. - Enhanced error response format with additional fields for better context and debugging. - Updated error handling utilities to streamline error creation and retry logic. - Removed the deprecated `errorCodes.ts` file and migrated relevant functionality to `apiErrors.ts`. - Updated documentation to reflect changes in error handling practices and structures.
This commit is contained in:
parent
e4ec7bba7c
commit
939100d495
@ -9,7 +9,7 @@ packages/shared/data/api/
|
||||
├── index.ts # Barrel export for infrastructure types
|
||||
├── apiTypes.ts # Core request/response types and API utilities
|
||||
├── apiPaths.ts # Path template literal type utilities
|
||||
├── errorCodes.ts # Error handling utilities and factories
|
||||
├── apiErrors.ts # Error handling: ErrorCode, DataApiError class, factory
|
||||
└── schemas/
|
||||
├── index.ts # Schema composition (merges all domain schemas)
|
||||
└── test.ts # Test API schema and DTOs
|
||||
@ -21,7 +21,7 @@ packages/shared/data/api/
|
||||
|------|---------|
|
||||
| `apiTypes.ts` | Core types (`DataRequest`, `DataResponse`, `ApiClient`) and schema utilities |
|
||||
| `apiPaths.ts` | Template literal types for path resolution (`/items/:id` → `/items/${string}`) |
|
||||
| `errorCodes.ts` | `DataApiErrorFactory`, error codes, and error handling utilities |
|
||||
| `apiErrors.ts` | `ErrorCode` enum, `DataApiError` class, `DataApiErrorFactory`, retryability config |
|
||||
| `index.ts` | Unified export of infrastructure types (not domain DTOs) |
|
||||
| `schemas/index.ts` | Composes all domain schemas into `ApiSchemas` using intersection types |
|
||||
| `schemas/*.ts` | Domain-specific API definitions and DTOs |
|
||||
@ -37,11 +37,16 @@ import type {
|
||||
DataRequest,
|
||||
DataResponse,
|
||||
ApiClient,
|
||||
PaginatedResponse,
|
||||
ErrorCode
|
||||
PaginatedResponse
|
||||
} from '@shared/data/api'
|
||||
|
||||
import { DataApiErrorFactory, isDataApiError } from '@shared/data/api'
|
||||
import {
|
||||
ErrorCode,
|
||||
DataApiError,
|
||||
DataApiErrorFactory,
|
||||
isDataApiError,
|
||||
toDataApiError
|
||||
} from '@shared/data/api'
|
||||
```
|
||||
|
||||
### Domain DTOs (directly from schema files)
|
||||
@ -153,22 +158,65 @@ await api.post('/topics', { body: { name: 'New' } }) // Body is typed as Create
|
||||
|
||||
## Error Handling
|
||||
|
||||
Use `DataApiErrorFactory` for consistent error creation:
|
||||
The error system provides type-safe error handling with automatic retryability detection:
|
||||
|
||||
```typescript
|
||||
import { DataApiErrorFactory, ErrorCode } from '@shared/data/api'
|
||||
import {
|
||||
DataApiError,
|
||||
DataApiErrorFactory,
|
||||
ErrorCode,
|
||||
isDataApiError,
|
||||
toDataApiError
|
||||
} from '@shared/data/api'
|
||||
|
||||
// Create errors
|
||||
// Create errors using the factory (recommended)
|
||||
throw DataApiErrorFactory.notFound('Topic', id)
|
||||
throw DataApiErrorFactory.validationError('Name is required')
|
||||
throw DataApiErrorFactory.fromCode(ErrorCode.DATABASE_ERROR, 'Connection failed')
|
||||
throw DataApiErrorFactory.validation({ name: ['Name is required'] })
|
||||
throw DataApiErrorFactory.timeout('fetch topics', 3000)
|
||||
throw DataApiErrorFactory.database(originalError, 'insert topic')
|
||||
|
||||
// Check errors
|
||||
if (isDataApiError(error)) {
|
||||
console.log(error.code, error.status)
|
||||
// Or create directly with the class
|
||||
throw new DataApiError(
|
||||
ErrorCode.NOT_FOUND,
|
||||
'Topic not found',
|
||||
404,
|
||||
{ resource: 'Topic', id: 'abc123' }
|
||||
)
|
||||
|
||||
// Check if error is retryable (for automatic retry logic)
|
||||
if (error instanceof DataApiError && error.isRetryable) {
|
||||
await retry(operation)
|
||||
}
|
||||
|
||||
// Check error type
|
||||
if (error instanceof DataApiError) {
|
||||
if (error.isClientError) {
|
||||
// 4xx - issue with the request
|
||||
} else if (error.isServerError) {
|
||||
// 5xx - server-side issue
|
||||
}
|
||||
}
|
||||
|
||||
// Convert any error to DataApiError
|
||||
const apiError = toDataApiError(unknownError, 'context')
|
||||
|
||||
// Serialize for IPC (Main → Renderer)
|
||||
const serialized = apiError.toJSON()
|
||||
|
||||
// Reconstruct from IPC response (Renderer)
|
||||
const reconstructed = DataApiError.fromJSON(response.error)
|
||||
```
|
||||
|
||||
### Retryable Error Codes
|
||||
|
||||
The following errors are automatically considered retryable:
|
||||
- `SERVICE_UNAVAILABLE` (503)
|
||||
- `TIMEOUT` (504)
|
||||
- `RATE_LIMIT_EXCEEDED` (429)
|
||||
- `DATABASE_ERROR` (500)
|
||||
- `INTERNAL_SERVER_ERROR` (500)
|
||||
- `RESOURCE_LOCKED` (423)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
|
||||
@ -147,24 +147,33 @@ Use standard HTTP status codes consistently:
|
||||
| 204 No Content | Successful DELETE | No body |
|
||||
| 400 Bad Request | Invalid request format | Malformed JSON |
|
||||
| 401 Unauthorized | Authentication required | Missing/invalid token |
|
||||
| 403 Forbidden | Permission denied | Insufficient access |
|
||||
| 403 Permission Denied | Insufficient permissions | Access denied to resource |
|
||||
| 404 Not Found | Resource not found | Invalid ID |
|
||||
| 409 Conflict | Concurrent modification | Version conflict |
|
||||
| 409 Conflict | Concurrent modification or data inconsistency | Version conflict, data corruption |
|
||||
| 422 Unprocessable | Validation failed | Invalid field values |
|
||||
| 423 Locked | Resource temporarily locked | File being exported |
|
||||
| 429 Too Many Requests | Rate limit exceeded | Throttling |
|
||||
| 500 Internal Error | Server error | Unexpected failure |
|
||||
| 503 Service Unavailable | Service temporarily down | Maintenance mode |
|
||||
| 504 Timeout | Request timed out | Long-running operation |
|
||||
|
||||
## Error Response Format
|
||||
|
||||
All error responses follow the `DataApiError` structure:
|
||||
All error responses follow the `SerializedDataApiError` structure (transmitted via IPC):
|
||||
|
||||
```typescript
|
||||
interface DataApiError {
|
||||
code: string // ErrorCode enum value (e.g., 'NOT_FOUND')
|
||||
message: string // Human-readable error message
|
||||
status: number // HTTP status code
|
||||
details?: any // Additional context (e.g., field errors)
|
||||
stack?: string // Stack trace (development only)
|
||||
interface SerializedDataApiError {
|
||||
code: ErrorCode | string // ErrorCode enum value (e.g., 'NOT_FOUND')
|
||||
message: string // Human-readable error message
|
||||
status: number // HTTP status code
|
||||
details?: Record<string, unknown> // Additional context (e.g., field errors)
|
||||
requestContext?: { // Request context for debugging
|
||||
requestId: string
|
||||
path: string
|
||||
method: HttpMethod
|
||||
timestamp?: number
|
||||
}
|
||||
// Note: stack trace is NOT transmitted via IPC - rely on Main process logs
|
||||
}
|
||||
```
|
||||
|
||||
@ -176,7 +185,8 @@ interface DataApiError {
|
||||
code: 'NOT_FOUND',
|
||||
message: "Topic with id 'abc123' not found",
|
||||
status: 404,
|
||||
details: { resource: 'Topic', id: 'abc123' }
|
||||
details: { resource: 'Topic', id: 'abc123' },
|
||||
requestContext: { requestId: 'req_123', path: '/topics/abc123', method: 'GET' }
|
||||
}
|
||||
|
||||
// 422 Validation Error
|
||||
@ -191,16 +201,32 @@ interface DataApiError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 504 Timeout
|
||||
{
|
||||
code: 'TIMEOUT',
|
||||
message: 'Request timeout: fetch topics (3000ms)',
|
||||
status: 504,
|
||||
details: { operation: 'fetch topics', timeoutMs: 3000 }
|
||||
}
|
||||
```
|
||||
|
||||
Use `DataApiErrorFactory` utilities to create consistent errors:
|
||||
|
||||
```typescript
|
||||
import { DataApiErrorFactory } from '@shared/data/api'
|
||||
import { DataApiErrorFactory, DataApiError } from '@shared/data/api'
|
||||
|
||||
// Using factory methods (recommended)
|
||||
throw DataApiErrorFactory.notFound('Topic', id)
|
||||
throw DataApiErrorFactory.validation({ name: ['Required'] })
|
||||
throw DataApiErrorFactory.database(error, 'insert topic')
|
||||
throw DataApiErrorFactory.timeout('fetch topics', 3000)
|
||||
throw DataApiErrorFactory.dataInconsistent('Topic', 'parent reference broken')
|
||||
|
||||
// Check if error is retryable
|
||||
if (error instanceof DataApiError && error.isRetryable) {
|
||||
await retry(operation)
|
||||
}
|
||||
```
|
||||
|
||||
## Naming Conventions Summary
|
||||
|
||||
776
packages/shared/data/api/apiErrors.ts
Normal file
776
packages/shared/data/api/apiErrors.ts
Normal file
@ -0,0 +1,776 @@
|
||||
/**
|
||||
* @fileoverview Centralized error handling for the Data API system
|
||||
*
|
||||
* This module provides comprehensive error management including:
|
||||
* - ErrorCode enum with HTTP status mapping
|
||||
* - Type-safe error details for each error type
|
||||
* - DataApiError class for structured error handling
|
||||
* - DataApiErrorFactory for convenient error creation
|
||||
* - Retryability configuration for automatic retry logic
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { DataApiError, DataApiErrorFactory, ErrorCode } from '@shared/data/api'
|
||||
*
|
||||
* // Create and throw an error
|
||||
* throw DataApiErrorFactory.notFound('Topic', 'abc123')
|
||||
*
|
||||
* // Check if error is retryable
|
||||
* if (error instanceof DataApiError && error.isRetryable) {
|
||||
* await retry(operation)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { HttpMethod } from './apiTypes'
|
||||
|
||||
// ============================================================================
|
||||
// Error Code Enum
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Standard error codes for the Data API system.
|
||||
* Maps to HTTP status codes via ERROR_STATUS_MAP.
|
||||
*/
|
||||
export enum ErrorCode {
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Client errors (4xx) - Issues with the request itself
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 400 - Malformed request syntax or invalid parameters */
|
||||
BAD_REQUEST = 'BAD_REQUEST',
|
||||
|
||||
/** 401 - Authentication required or credentials invalid */
|
||||
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||
|
||||
/** 404 - Requested resource does not exist */
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
|
||||
/** 405 - HTTP method not supported for this endpoint */
|
||||
METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED',
|
||||
|
||||
/** 422 - Request body fails validation rules */
|
||||
VALIDATION_ERROR = 'VALIDATION_ERROR',
|
||||
|
||||
/** 429 - Too many requests, retry after delay */
|
||||
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
|
||||
|
||||
/** 403 - Authenticated but lacks required permissions */
|
||||
PERMISSION_DENIED = 'PERMISSION_DENIED',
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Server errors (5xx) - Issues on the server side
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 500 - Unexpected server error */
|
||||
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
|
||||
|
||||
/** 500 - Database operation failed (connection, query, constraint) */
|
||||
DATABASE_ERROR = 'DATABASE_ERROR',
|
||||
|
||||
/** 503 - Service temporarily unavailable, retry later */
|
||||
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
|
||||
|
||||
/** 504 - Request timed out waiting for response */
|
||||
TIMEOUT = 'TIMEOUT',
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Application-specific errors
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 500 - Data migration process failed */
|
||||
MIGRATION_ERROR = 'MIGRATION_ERROR',
|
||||
|
||||
/**
|
||||
* 423 - Resource is temporarily locked by another operation.
|
||||
* Use when: file being exported, data migration in progress,
|
||||
* or resource held by background process.
|
||||
* Retryable: Yes (resource may be released)
|
||||
*/
|
||||
RESOURCE_LOCKED = 'RESOURCE_LOCKED',
|
||||
|
||||
/**
|
||||
* 409 - Optimistic lock conflict, resource was modified after read.
|
||||
* Use when: multi-window editing same topic, version mismatch
|
||||
* on update, or stale data detected during save.
|
||||
* Client should: refresh data and retry or notify user.
|
||||
*/
|
||||
CONCURRENT_MODIFICATION = 'CONCURRENT_MODIFICATION',
|
||||
|
||||
/**
|
||||
* 409 - Data integrity violation or inconsistent state detected.
|
||||
* Use when: referential integrity broken, computed values mismatch,
|
||||
* or data corruption found during validation.
|
||||
* Not retryable: requires investigation or data repair.
|
||||
*/
|
||||
DATA_INCONSISTENT = 'DATA_INCONSISTENT'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Code Mappings
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Maps error codes to HTTP status codes.
|
||||
* Used by DataApiError and DataApiErrorFactory.
|
||||
*/
|
||||
export const ERROR_STATUS_MAP: Record<ErrorCode, number> = {
|
||||
// Client errors (4xx)
|
||||
[ErrorCode.BAD_REQUEST]: 400,
|
||||
[ErrorCode.UNAUTHORIZED]: 401,
|
||||
[ErrorCode.NOT_FOUND]: 404,
|
||||
[ErrorCode.METHOD_NOT_ALLOWED]: 405,
|
||||
[ErrorCode.VALIDATION_ERROR]: 422,
|
||||
[ErrorCode.RATE_LIMIT_EXCEEDED]: 429,
|
||||
[ErrorCode.PERMISSION_DENIED]: 403,
|
||||
|
||||
// Server errors (5xx)
|
||||
[ErrorCode.INTERNAL_SERVER_ERROR]: 500,
|
||||
[ErrorCode.DATABASE_ERROR]: 500,
|
||||
[ErrorCode.SERVICE_UNAVAILABLE]: 503,
|
||||
[ErrorCode.TIMEOUT]: 504,
|
||||
|
||||
// Application-specific errors
|
||||
[ErrorCode.MIGRATION_ERROR]: 500,
|
||||
[ErrorCode.RESOURCE_LOCKED]: 423,
|
||||
[ErrorCode.CONCURRENT_MODIFICATION]: 409,
|
||||
[ErrorCode.DATA_INCONSISTENT]: 409
|
||||
}
|
||||
|
||||
/**
|
||||
* Default error messages for each error code.
|
||||
* Used when no custom message is provided.
|
||||
*/
|
||||
export const ERROR_MESSAGES: Record<ErrorCode, string> = {
|
||||
[ErrorCode.BAD_REQUEST]: 'Bad request: Invalid request format or parameters',
|
||||
[ErrorCode.UNAUTHORIZED]: 'Unauthorized: Authentication required',
|
||||
[ErrorCode.NOT_FOUND]: 'Not found: Requested resource does not exist',
|
||||
[ErrorCode.METHOD_NOT_ALLOWED]: 'Method not allowed: HTTP method not supported for this endpoint',
|
||||
[ErrorCode.VALIDATION_ERROR]: 'Validation error: Request data does not meet requirements',
|
||||
[ErrorCode.RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded: Too many requests',
|
||||
[ErrorCode.PERMISSION_DENIED]: 'Permission denied: Insufficient permissions for this operation',
|
||||
|
||||
[ErrorCode.INTERNAL_SERVER_ERROR]: 'Internal server error: An unexpected error occurred',
|
||||
[ErrorCode.DATABASE_ERROR]: 'Database error: Failed to access or modify data',
|
||||
[ErrorCode.SERVICE_UNAVAILABLE]: 'Service unavailable: The service is temporarily unavailable',
|
||||
[ErrorCode.TIMEOUT]: 'Timeout: Request timed out waiting for response',
|
||||
|
||||
[ErrorCode.MIGRATION_ERROR]: 'Migration error: Failed to migrate data',
|
||||
[ErrorCode.RESOURCE_LOCKED]: 'Resource locked: Resource is currently locked by another operation',
|
||||
[ErrorCode.CONCURRENT_MODIFICATION]: 'Concurrent modification: Resource was modified by another user',
|
||||
[ErrorCode.DATA_INCONSISTENT]: 'Data inconsistent: Data integrity violation detected'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Request Context
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request context attached to errors for debugging and logging.
|
||||
* Always transmitted via IPC for frontend display.
|
||||
*/
|
||||
export interface RequestContext {
|
||||
/** Unique identifier for request correlation */
|
||||
requestId: string
|
||||
/** API path that was called */
|
||||
path: string
|
||||
/** HTTP method used */
|
||||
method: HttpMethod
|
||||
/** Timestamp when request was initiated */
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error-specific Detail Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Details for VALIDATION_ERROR - field-level validation failures.
|
||||
* Maps field names to arrays of error messages.
|
||||
*/
|
||||
export interface ValidationErrorDetails {
|
||||
fieldErrors: Record<string, string[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Details for NOT_FOUND - which resource was not found.
|
||||
*/
|
||||
export interface NotFoundErrorDetails {
|
||||
resource: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Details for DATABASE_ERROR - underlying database failure info.
|
||||
*/
|
||||
export interface DatabaseErrorDetails {
|
||||
originalError: string
|
||||
operation?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Details for TIMEOUT - what operation timed out.
|
||||
*/
|
||||
export interface TimeoutErrorDetails {
|
||||
operation?: string
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Details for DATA_INCONSISTENT - what data is inconsistent.
|
||||
*/
|
||||
export interface DataInconsistentErrorDetails {
|
||||
resource: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Details for PERMISSION_DENIED - what action was denied.
|
||||
*/
|
||||
export interface PermissionDeniedErrorDetails {
|
||||
action: string
|
||||
resource?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Details for RESOURCE_LOCKED - which resource is locked.
|
||||
*/
|
||||
export interface ResourceLockedErrorDetails {
|
||||
resource: string
|
||||
id: string
|
||||
lockedBy?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Details for CONCURRENT_MODIFICATION - which resource was concurrently modified.
|
||||
*/
|
||||
export interface ConcurrentModificationErrorDetails {
|
||||
resource: string
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Details for INTERNAL_SERVER_ERROR - context about the failure.
|
||||
*/
|
||||
export interface InternalErrorDetails {
|
||||
originalError: string
|
||||
context?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Type Mapping for Error Details
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Maps error codes to their specific detail types.
|
||||
* Only define for error codes that have structured details.
|
||||
*/
|
||||
export type ErrorDetailsMap = {
|
||||
[ErrorCode.VALIDATION_ERROR]: ValidationErrorDetails
|
||||
[ErrorCode.NOT_FOUND]: NotFoundErrorDetails
|
||||
[ErrorCode.DATABASE_ERROR]: DatabaseErrorDetails
|
||||
[ErrorCode.TIMEOUT]: TimeoutErrorDetails
|
||||
[ErrorCode.DATA_INCONSISTENT]: DataInconsistentErrorDetails
|
||||
[ErrorCode.PERMISSION_DENIED]: PermissionDeniedErrorDetails
|
||||
[ErrorCode.RESOURCE_LOCKED]: ResourceLockedErrorDetails
|
||||
[ErrorCode.CONCURRENT_MODIFICATION]: ConcurrentModificationErrorDetails
|
||||
[ErrorCode.INTERNAL_SERVER_ERROR]: InternalErrorDetails
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the detail type for a specific error code.
|
||||
* Falls back to generic Record for unmapped codes.
|
||||
*/
|
||||
export type DetailsForCode<T extends ErrorCode> = T extends keyof ErrorDetailsMap
|
||||
? ErrorDetailsMap[T]
|
||||
: Record<string, unknown> | undefined
|
||||
|
||||
// ============================================================================
|
||||
// Retryability Configuration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Set of error codes that are safe to retry automatically.
|
||||
* These represent temporary failures that may succeed on retry.
|
||||
*/
|
||||
export const RETRYABLE_ERROR_CODES: ReadonlySet<ErrorCode> = new Set([
|
||||
ErrorCode.SERVICE_UNAVAILABLE, // 503 - Service temporarily down
|
||||
ErrorCode.TIMEOUT, // 504 - Request timed out
|
||||
ErrorCode.RATE_LIMIT_EXCEEDED, // 429 - Can retry after delay
|
||||
ErrorCode.DATABASE_ERROR, // 500 - Temporary DB issues
|
||||
ErrorCode.INTERNAL_SERVER_ERROR, // 500 - May be transient
|
||||
ErrorCode.RESOURCE_LOCKED // 423 - Lock may be released
|
||||
])
|
||||
|
||||
/**
|
||||
* Check if an error code represents a retryable condition.
|
||||
* @param code - The error code to check
|
||||
* @returns true if the error is safe to retry
|
||||
*/
|
||||
export function isRetryableErrorCode(code: ErrorCode): boolean {
|
||||
return RETRYABLE_ERROR_CODES.has(code)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Serialized Error Interface (for IPC transmission)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Serialized error structure for IPC transmission.
|
||||
* Used in DataResponse.error field.
|
||||
* Note: Does not include stack trace - rely on Main process logs.
|
||||
*/
|
||||
export interface SerializedDataApiError {
|
||||
/** Error code from ErrorCode enum */
|
||||
code: ErrorCode | string
|
||||
/** Human-readable error message */
|
||||
message: string
|
||||
/** HTTP status code */
|
||||
status: number
|
||||
/** Structured error details */
|
||||
details?: Record<string, unknown>
|
||||
/** Request context for debugging */
|
||||
requestContext?: RequestContext
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DataApiError Class
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Custom error class for Data API errors.
|
||||
*
|
||||
* Provides type-safe error handling with:
|
||||
* - Typed error codes and details
|
||||
* - Retryability checking via `isRetryable` getter
|
||||
* - IPC serialization via `toJSON()` / `fromJSON()`
|
||||
* - Request context for debugging
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Throw a typed error
|
||||
* throw new DataApiError(
|
||||
* ErrorCode.NOT_FOUND,
|
||||
* 'Topic not found',
|
||||
* 404,
|
||||
* { resource: 'Topic', id: 'abc123' }
|
||||
* )
|
||||
*
|
||||
* // Check if error is retryable
|
||||
* if (error.isRetryable) {
|
||||
* await retry(operation)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class DataApiError<T extends ErrorCode = ErrorCode> extends Error {
|
||||
/** Error code from ErrorCode enum */
|
||||
public readonly code: T
|
||||
/** HTTP status code */
|
||||
public readonly status: number
|
||||
/** Structured error details (type depends on error code) */
|
||||
public readonly details?: DetailsForCode<T>
|
||||
/** Request context for debugging */
|
||||
public readonly requestContext?: RequestContext
|
||||
|
||||
constructor(code: T, message: string, status: number, details?: DetailsForCode<T>, requestContext?: RequestContext) {
|
||||
super(message)
|
||||
this.name = 'DataApiError'
|
||||
this.code = code
|
||||
this.status = status
|
||||
this.details = details
|
||||
this.requestContext = requestContext
|
||||
|
||||
// Maintains proper stack trace for where error was thrown
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, DataApiError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this error is safe to retry automatically.
|
||||
* Based on the RETRYABLE_ERROR_CODES configuration.
|
||||
*/
|
||||
get isRetryable(): boolean {
|
||||
return isRetryableErrorCode(this.code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this is a client error (4xx status).
|
||||
* Client errors typically indicate issues with the request itself.
|
||||
*/
|
||||
get isClientError(): boolean {
|
||||
return this.status >= 400 && this.status < 500
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this is a server error (5xx status).
|
||||
* Server errors typically indicate issues on the server side.
|
||||
*/
|
||||
get isServerError(): boolean {
|
||||
return this.status >= 500 && this.status < 600
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize for IPC transmission.
|
||||
* Note: Stack trace is NOT included - rely on Main process logs.
|
||||
* @returns Serialized error object for IPC
|
||||
*/
|
||||
toJSON(): SerializedDataApiError {
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
status: this.status,
|
||||
details: this.details as Record<string, unknown> | undefined,
|
||||
requestContext: this.requestContext
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct DataApiError from IPC response.
|
||||
* @param error - Serialized error from IPC
|
||||
* @returns DataApiError instance
|
||||
*/
|
||||
static fromJSON(error: SerializedDataApiError): DataApiError {
|
||||
return new DataApiError(error.code as ErrorCode, error.message, error.status, error.details, error.requestContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create DataApiError from a generic Error.
|
||||
* @param error - Original error
|
||||
* @param code - Error code to use (defaults to INTERNAL_SERVER_ERROR)
|
||||
* @param requestContext - Optional request context
|
||||
* @returns DataApiError instance
|
||||
*/
|
||||
static fromError(
|
||||
error: Error,
|
||||
code: ErrorCode = ErrorCode.INTERNAL_SERVER_ERROR,
|
||||
requestContext?: RequestContext
|
||||
): DataApiError {
|
||||
return new DataApiError(
|
||||
code,
|
||||
error.message,
|
||||
ERROR_STATUS_MAP[code],
|
||||
{ originalError: error.message, context: error.name } as DetailsForCode<typeof code>,
|
||||
requestContext
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DataApiErrorFactory
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Factory for creating standardized DataApiError instances.
|
||||
* Provides convenience methods for common error types with proper typing.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Create a not found error
|
||||
* throw DataApiErrorFactory.notFound('Topic', 'abc123')
|
||||
*
|
||||
* // Create a validation error
|
||||
* throw DataApiErrorFactory.validation({
|
||||
* name: ['Name is required'],
|
||||
* email: ['Invalid email format']
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export class DataApiErrorFactory {
|
||||
/**
|
||||
* Create a DataApiError with any error code.
|
||||
* Use specialized methods when available for better type safety.
|
||||
* @param code - Error code from ErrorCode enum
|
||||
* @param customMessage - Optional custom error message
|
||||
* @param details - Optional structured error details
|
||||
* @param requestContext - Optional request context
|
||||
* @returns DataApiError instance
|
||||
*/
|
||||
static create<T extends ErrorCode>(
|
||||
code: T,
|
||||
customMessage?: string,
|
||||
details?: DetailsForCode<T>,
|
||||
requestContext?: RequestContext
|
||||
): DataApiError<T> {
|
||||
return new DataApiError(
|
||||
code,
|
||||
customMessage || ERROR_MESSAGES[code],
|
||||
ERROR_STATUS_MAP[code],
|
||||
details,
|
||||
requestContext
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a validation error with field-specific error messages.
|
||||
* @param fieldErrors - Map of field names to error messages
|
||||
* @param message - Optional custom message
|
||||
* @param requestContext - Optional request context
|
||||
* @returns DataApiError with VALIDATION_ERROR code
|
||||
*/
|
||||
static validation(
|
||||
fieldErrors: Record<string, string[]>,
|
||||
message?: string,
|
||||
requestContext?: RequestContext
|
||||
): DataApiError<ErrorCode.VALIDATION_ERROR> {
|
||||
return new DataApiError(
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
message || 'Request validation failed',
|
||||
ERROR_STATUS_MAP[ErrorCode.VALIDATION_ERROR],
|
||||
{ fieldErrors },
|
||||
requestContext
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a not found error for a specific resource.
|
||||
* @param resource - Resource type name (e.g., 'Topic', 'Message')
|
||||
* @param id - Optional resource identifier
|
||||
* @param requestContext - Optional request context
|
||||
* @returns DataApiError with NOT_FOUND code
|
||||
*/
|
||||
static notFound(resource: string, id?: string, requestContext?: RequestContext): DataApiError<ErrorCode.NOT_FOUND> {
|
||||
const message = id ? `${resource} with id '${id}' not found` : `${resource} not found`
|
||||
return new DataApiError(
|
||||
ErrorCode.NOT_FOUND,
|
||||
message,
|
||||
ERROR_STATUS_MAP[ErrorCode.NOT_FOUND],
|
||||
{ resource, id },
|
||||
requestContext
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a database error from an original error.
|
||||
* @param originalError - The underlying database error
|
||||
* @param operation - Description of the failed operation
|
||||
* @param requestContext - Optional request context
|
||||
* @returns DataApiError with DATABASE_ERROR code
|
||||
*/
|
||||
static database(
|
||||
originalError: Error,
|
||||
operation?: string,
|
||||
requestContext?: RequestContext
|
||||
): DataApiError<ErrorCode.DATABASE_ERROR> {
|
||||
return new DataApiError(
|
||||
ErrorCode.DATABASE_ERROR,
|
||||
`Database operation failed${operation ? `: ${operation}` : ''}`,
|
||||
ERROR_STATUS_MAP[ErrorCode.DATABASE_ERROR],
|
||||
{ originalError: originalError.message, operation },
|
||||
requestContext
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an internal server error from an unexpected error.
|
||||
* @param originalError - The underlying error
|
||||
* @param context - Additional context about where the error occurred
|
||||
* @param requestContext - Optional request context
|
||||
* @returns DataApiError with INTERNAL_SERVER_ERROR code
|
||||
*/
|
||||
static internal(
|
||||
originalError: Error,
|
||||
context?: string,
|
||||
requestContext?: RequestContext
|
||||
): DataApiError<ErrorCode.INTERNAL_SERVER_ERROR> {
|
||||
const message = context
|
||||
? `Internal error in ${context}: ${originalError.message}`
|
||||
: `Internal error: ${originalError.message}`
|
||||
return new DataApiError(
|
||||
ErrorCode.INTERNAL_SERVER_ERROR,
|
||||
message,
|
||||
ERROR_STATUS_MAP[ErrorCode.INTERNAL_SERVER_ERROR],
|
||||
{ originalError: originalError.message, context },
|
||||
requestContext
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a permission denied error.
|
||||
* @param action - The action that was denied
|
||||
* @param resource - Optional resource that access was denied to
|
||||
* @param requestContext - Optional request context
|
||||
* @returns DataApiError with PERMISSION_DENIED code
|
||||
*/
|
||||
static permissionDenied(
|
||||
action: string,
|
||||
resource?: string,
|
||||
requestContext?: RequestContext
|
||||
): DataApiError<ErrorCode.PERMISSION_DENIED> {
|
||||
const message = resource ? `Permission denied: Cannot ${action} ${resource}` : `Permission denied: Cannot ${action}`
|
||||
return new DataApiError(
|
||||
ErrorCode.PERMISSION_DENIED,
|
||||
message,
|
||||
ERROR_STATUS_MAP[ErrorCode.PERMISSION_DENIED],
|
||||
{ action, resource },
|
||||
requestContext
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a timeout error.
|
||||
* @param operation - Description of the operation that timed out
|
||||
* @param timeoutMs - The timeout duration in milliseconds
|
||||
* @param requestContext - Optional request context
|
||||
* @returns DataApiError with TIMEOUT code
|
||||
*/
|
||||
static timeout(
|
||||
operation?: string,
|
||||
timeoutMs?: number,
|
||||
requestContext?: RequestContext
|
||||
): DataApiError<ErrorCode.TIMEOUT> {
|
||||
const message = operation
|
||||
? `Request timeout: ${operation}${timeoutMs ? ` (${timeoutMs}ms)` : ''}`
|
||||
: `Request timeout${timeoutMs ? ` (${timeoutMs}ms)` : ''}`
|
||||
return new DataApiError(
|
||||
ErrorCode.TIMEOUT,
|
||||
message,
|
||||
ERROR_STATUS_MAP[ErrorCode.TIMEOUT],
|
||||
{ operation, timeoutMs },
|
||||
requestContext
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a data inconsistency error.
|
||||
* @param resource - The resource with inconsistent data
|
||||
* @param description - Description of the inconsistency
|
||||
* @param requestContext - Optional request context
|
||||
* @returns DataApiError with DATA_INCONSISTENT code
|
||||
*/
|
||||
static dataInconsistent(
|
||||
resource: string,
|
||||
description?: string,
|
||||
requestContext?: RequestContext
|
||||
): DataApiError<ErrorCode.DATA_INCONSISTENT> {
|
||||
const message = description
|
||||
? `Data inconsistent in ${resource}: ${description}`
|
||||
: `Data inconsistent in ${resource}`
|
||||
return new DataApiError(
|
||||
ErrorCode.DATA_INCONSISTENT,
|
||||
message,
|
||||
ERROR_STATUS_MAP[ErrorCode.DATA_INCONSISTENT],
|
||||
{ resource, description },
|
||||
requestContext
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a resource locked error.
|
||||
* Use when a resource is temporarily unavailable due to:
|
||||
* - File being exported
|
||||
* - Data migration in progress
|
||||
* - Resource held by background process
|
||||
*
|
||||
* @param resource - Resource type name
|
||||
* @param id - Resource identifier
|
||||
* @param lockedBy - Optional description of what's holding the lock
|
||||
* @param requestContext - Optional request context
|
||||
* @returns DataApiError with RESOURCE_LOCKED code
|
||||
*/
|
||||
static resourceLocked(
|
||||
resource: string,
|
||||
id: string,
|
||||
lockedBy?: string,
|
||||
requestContext?: RequestContext
|
||||
): DataApiError<ErrorCode.RESOURCE_LOCKED> {
|
||||
const message = lockedBy
|
||||
? `${resource} '${id}' is locked by ${lockedBy}`
|
||||
: `${resource} '${id}' is currently locked`
|
||||
return new DataApiError(
|
||||
ErrorCode.RESOURCE_LOCKED,
|
||||
message,
|
||||
ERROR_STATUS_MAP[ErrorCode.RESOURCE_LOCKED],
|
||||
{ resource, id, lockedBy },
|
||||
requestContext
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a concurrent modification error.
|
||||
* Use when an optimistic lock conflict occurs:
|
||||
* - Multi-window editing same topic
|
||||
* - Version mismatch on update
|
||||
* - Stale data detected during save
|
||||
*
|
||||
* Client should refresh data and retry or notify user.
|
||||
*
|
||||
* @param resource - Resource type name
|
||||
* @param id - Resource identifier
|
||||
* @param requestContext - Optional request context
|
||||
* @returns DataApiError with CONCURRENT_MODIFICATION code
|
||||
*/
|
||||
static concurrentModification(
|
||||
resource: string,
|
||||
id: string,
|
||||
requestContext?: RequestContext
|
||||
): DataApiError<ErrorCode.CONCURRENT_MODIFICATION> {
|
||||
return new DataApiError(
|
||||
ErrorCode.CONCURRENT_MODIFICATION,
|
||||
`${resource} '${id}' was modified by another user`,
|
||||
ERROR_STATUS_MAP[ErrorCode.CONCURRENT_MODIFICATION],
|
||||
{ resource, id },
|
||||
requestContext
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if an error is a DataApiError instance.
|
||||
* @param error - Any error object
|
||||
* @returns true if the error is a DataApiError
|
||||
*/
|
||||
export function isDataApiError(error: unknown): error is DataApiError {
|
||||
return error instanceof DataApiError
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an object is a serialized DataApiError.
|
||||
* @param error - Any object
|
||||
* @returns true if the object has DataApiError structure
|
||||
*/
|
||||
export function isSerializedDataApiError(error: unknown): error is SerializedDataApiError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
'message' in error &&
|
||||
'status' in error &&
|
||||
typeof (error as SerializedDataApiError).code === 'string' &&
|
||||
typeof (error as SerializedDataApiError).message === 'string' &&
|
||||
typeof (error as SerializedDataApiError).status === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any error to a DataApiError.
|
||||
* If already a DataApiError, returns as-is.
|
||||
* Otherwise, wraps in an INTERNAL_SERVER_ERROR.
|
||||
*
|
||||
* @param error - Any error
|
||||
* @param context - Optional context description
|
||||
* @returns DataApiError instance
|
||||
*/
|
||||
export function toDataApiError(error: unknown, context?: string): DataApiError {
|
||||
if (isDataApiError(error)) {
|
||||
return error
|
||||
}
|
||||
|
||||
if (isSerializedDataApiError(error)) {
|
||||
return DataApiError.fromJSON(error)
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return DataApiErrorFactory.internal(error, context)
|
||||
}
|
||||
|
||||
return DataApiErrorFactory.create(
|
||||
ErrorCode.INTERNAL_SERVER_ERROR,
|
||||
`Unknown error${context ? ` in ${context}` : ''}: ${String(error)}`,
|
||||
{ originalError: String(error), context } as DetailsForCode<ErrorCode.INTERNAL_SERVER_ERROR>
|
||||
)
|
||||
}
|
||||
@ -113,7 +113,7 @@ export interface DataResponse<T = any> {
|
||||
/** Response data if successful */
|
||||
data?: T
|
||||
/** Error information if request failed */
|
||||
error?: DataApiError
|
||||
error?: SerializedDataApiError
|
||||
/** Response metadata */
|
||||
metadata?: {
|
||||
/** Request processing duration in milliseconds */
|
||||
@ -127,46 +127,12 @@ export interface DataResponse<T = any> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized error structure for Data API
|
||||
*/
|
||||
export interface DataApiError {
|
||||
/** Error code for programmatic handling */
|
||||
code: string
|
||||
/** Human-readable error message */
|
||||
message: string
|
||||
/** HTTP status code */
|
||||
status: number
|
||||
/** Additional error details */
|
||||
details?: any
|
||||
/** Error stack trace (development mode only) */
|
||||
stack?: string
|
||||
}
|
||||
// Note: Error types have been moved to apiErrors.ts
|
||||
// Import from there: ErrorCode, DataApiError, SerializedDataApiError, DataApiErrorFactory
|
||||
import type { SerializedDataApiError } from './apiErrors'
|
||||
|
||||
/**
|
||||
* Standard error codes for Data API
|
||||
*/
|
||||
export enum ErrorCode {
|
||||
// Client errors (4xx)
|
||||
BAD_REQUEST = 'BAD_REQUEST',
|
||||
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||
FORBIDDEN = 'FORBIDDEN',
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED',
|
||||
VALIDATION_ERROR = 'VALIDATION_ERROR',
|
||||
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
|
||||
|
||||
// Server errors (5xx)
|
||||
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
|
||||
DATABASE_ERROR = 'DATABASE_ERROR',
|
||||
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
|
||||
|
||||
// Custom application errors
|
||||
MIGRATION_ERROR = 'MIGRATION_ERROR',
|
||||
PERMISSION_DENIED = 'PERMISSION_DENIED',
|
||||
RESOURCE_LOCKED = 'RESOURCE_LOCKED',
|
||||
CONCURRENT_MODIFICATION = 'CONCURRENT_MODIFICATION'
|
||||
}
|
||||
// Re-export for backwards compatibility in DataResponse
|
||||
export type { SerializedDataApiError } from './apiErrors'
|
||||
|
||||
/**
|
||||
* Pagination parameters for list operations
|
||||
|
||||
@ -1,194 +0,0 @@
|
||||
/**
|
||||
* Centralized error code definitions for the Data API system
|
||||
* Provides consistent error handling across renderer and main processes
|
||||
*/
|
||||
|
||||
import type { DataApiError } from './apiTypes'
|
||||
import { ErrorCode } from './apiTypes'
|
||||
|
||||
// Re-export ErrorCode for convenience
|
||||
export { ErrorCode } from './apiTypes'
|
||||
|
||||
/**
|
||||
* Error code to HTTP status mapping
|
||||
*/
|
||||
export const ERROR_STATUS_MAP: Record<ErrorCode, number> = {
|
||||
// Client errors (4xx)
|
||||
[ErrorCode.BAD_REQUEST]: 400,
|
||||
[ErrorCode.UNAUTHORIZED]: 401,
|
||||
[ErrorCode.FORBIDDEN]: 403,
|
||||
[ErrorCode.NOT_FOUND]: 404,
|
||||
[ErrorCode.METHOD_NOT_ALLOWED]: 405,
|
||||
[ErrorCode.VALIDATION_ERROR]: 422,
|
||||
[ErrorCode.RATE_LIMIT_EXCEEDED]: 429,
|
||||
|
||||
// Server errors (5xx)
|
||||
[ErrorCode.INTERNAL_SERVER_ERROR]: 500,
|
||||
[ErrorCode.DATABASE_ERROR]: 500,
|
||||
[ErrorCode.SERVICE_UNAVAILABLE]: 503,
|
||||
|
||||
// Custom application errors (5xx)
|
||||
[ErrorCode.MIGRATION_ERROR]: 500,
|
||||
[ErrorCode.PERMISSION_DENIED]: 403,
|
||||
[ErrorCode.RESOURCE_LOCKED]: 423,
|
||||
[ErrorCode.CONCURRENT_MODIFICATION]: 409
|
||||
}
|
||||
|
||||
/**
|
||||
* Default error messages for each error code
|
||||
*/
|
||||
export const ERROR_MESSAGES: Record<ErrorCode, string> = {
|
||||
[ErrorCode.BAD_REQUEST]: 'Bad request: Invalid request format or parameters',
|
||||
[ErrorCode.UNAUTHORIZED]: 'Unauthorized: Authentication required',
|
||||
[ErrorCode.FORBIDDEN]: 'Forbidden: Insufficient permissions',
|
||||
[ErrorCode.NOT_FOUND]: 'Not found: Requested resource does not exist',
|
||||
[ErrorCode.METHOD_NOT_ALLOWED]: 'Method not allowed: HTTP method not supported for this endpoint',
|
||||
[ErrorCode.VALIDATION_ERROR]: 'Validation error: Request data does not meet requirements',
|
||||
[ErrorCode.RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded: Too many requests',
|
||||
|
||||
[ErrorCode.INTERNAL_SERVER_ERROR]: 'Internal server error: An unexpected error occurred',
|
||||
[ErrorCode.DATABASE_ERROR]: 'Database error: Failed to access or modify data',
|
||||
[ErrorCode.SERVICE_UNAVAILABLE]: 'Service unavailable: The service is temporarily unavailable',
|
||||
|
||||
[ErrorCode.MIGRATION_ERROR]: 'Migration error: Failed to migrate data',
|
||||
[ErrorCode.PERMISSION_DENIED]: 'Permission denied: Operation not allowed for current user',
|
||||
[ErrorCode.RESOURCE_LOCKED]: 'Resource locked: Resource is currently locked by another operation',
|
||||
[ErrorCode.CONCURRENT_MODIFICATION]: 'Concurrent modification: Resource was modified by another user'
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility class for creating standardized Data API errors
|
||||
*/
|
||||
export class DataApiErrorFactory {
|
||||
/**
|
||||
* Create a DataApiError with standard properties
|
||||
*/
|
||||
static create(code: ErrorCode, customMessage?: string, details?: any, stack?: string): DataApiError {
|
||||
return {
|
||||
code,
|
||||
message: customMessage || ERROR_MESSAGES[code],
|
||||
status: ERROR_STATUS_MAP[code],
|
||||
details,
|
||||
stack: stack || undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a validation error with field-specific details
|
||||
*/
|
||||
static validation(fieldErrors: Record<string, string[]>, message?: string): DataApiError {
|
||||
return this.create(ErrorCode.VALIDATION_ERROR, message || 'Request validation failed', { fieldErrors })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a not found error for specific resource
|
||||
*/
|
||||
static notFound(resource: string, id?: string): DataApiError {
|
||||
const message = id ? `${resource} with id '${id}' not found` : `${resource} not found`
|
||||
|
||||
return this.create(ErrorCode.NOT_FOUND, message, { resource, id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a database error with query details
|
||||
*/
|
||||
static database(originalError: Error, operation?: string): DataApiError {
|
||||
return this.create(
|
||||
ErrorCode.DATABASE_ERROR,
|
||||
`Database operation failed${operation ? `: ${operation}` : ''}`,
|
||||
{
|
||||
originalError: originalError.message,
|
||||
operation
|
||||
},
|
||||
originalError.stack
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a permission denied error
|
||||
*/
|
||||
static permissionDenied(action: string, resource?: string): DataApiError {
|
||||
const message = resource ? `Permission denied: Cannot ${action} ${resource}` : `Permission denied: Cannot ${action}`
|
||||
|
||||
return this.create(ErrorCode.PERMISSION_DENIED, message, { action, resource })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an internal server error from an unexpected error
|
||||
*/
|
||||
static internal(originalError: Error, context?: string): DataApiError {
|
||||
const message = context
|
||||
? `Internal error in ${context}: ${originalError.message}`
|
||||
: `Internal error: ${originalError.message}`
|
||||
|
||||
return this.create(
|
||||
ErrorCode.INTERNAL_SERVER_ERROR,
|
||||
message,
|
||||
{ originalError: originalError.message, context },
|
||||
originalError.stack
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a rate limit exceeded error
|
||||
*/
|
||||
static rateLimit(limit: number, windowMs: number): DataApiError {
|
||||
return this.create(ErrorCode.RATE_LIMIT_EXCEEDED, `Rate limit exceeded: ${limit} requests per ${windowMs}ms`, {
|
||||
limit,
|
||||
windowMs
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a resource locked error
|
||||
*/
|
||||
static resourceLocked(resource: string, id: string, lockedBy?: string): DataApiError {
|
||||
const message = lockedBy
|
||||
? `${resource} '${id}' is locked by ${lockedBy}`
|
||||
: `${resource} '${id}' is currently locked`
|
||||
|
||||
return this.create(ErrorCode.RESOURCE_LOCKED, message, { resource, id, lockedBy })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a concurrent modification error
|
||||
*/
|
||||
static concurrentModification(resource: string, id: string): DataApiError {
|
||||
return this.create(ErrorCode.CONCURRENT_MODIFICATION, `${resource} '${id}' was modified by another user`, {
|
||||
resource,
|
||||
id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a Data API error
|
||||
*/
|
||||
export function isDataApiError(error: any): error is DataApiError {
|
||||
return (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
typeof error.code === 'string' &&
|
||||
typeof error.message === 'string' &&
|
||||
typeof error.status === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a generic error to a DataApiError
|
||||
*/
|
||||
export function toDataApiError(error: unknown, context?: string): DataApiError {
|
||||
if (isDataApiError(error)) {
|
||||
return error
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return DataApiErrorFactory.internal(error, context)
|
||||
}
|
||||
|
||||
return DataApiErrorFactory.create(
|
||||
ErrorCode.INTERNAL_SERVER_ERROR,
|
||||
`Unknown error${context ? ` in ${context}` : ''}: ${String(error)}`,
|
||||
{ originalError: error, context }
|
||||
)
|
||||
}
|
||||
@ -7,7 +7,7 @@
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Infrastructure types from barrel export
|
||||
* import { DataRequest, DataResponse, ErrorCode, ApiClient } from '@shared/data/api'
|
||||
* import { DataRequest, DataResponse, ErrorCode, DataApiError } from '@shared/data/api'
|
||||
*
|
||||
* // Domain DTOs from schema files directly
|
||||
* import type { Topic, CreateTopicDto } from '@shared/data/api/schemas/topic'
|
||||
@ -19,7 +19,6 @@
|
||||
// ============================================================================
|
||||
|
||||
export type {
|
||||
DataApiError,
|
||||
DataRequest,
|
||||
DataResponse,
|
||||
HttpMethod,
|
||||
@ -58,26 +57,47 @@ export type {
|
||||
} from './apiPaths'
|
||||
|
||||
// ============================================================================
|
||||
// Error Handling
|
||||
// Error Handling (from apiErrors.ts)
|
||||
// ============================================================================
|
||||
|
||||
export { ErrorCode, SubscriptionEvent } from './apiTypes'
|
||||
// Error code enum and mappings
|
||||
export {
|
||||
DataApiErrorFactory,
|
||||
ERROR_MESSAGES,
|
||||
ERROR_STATUS_MAP,
|
||||
ErrorCode,
|
||||
isRetryableErrorCode,
|
||||
RETRYABLE_ERROR_CODES
|
||||
} from './apiErrors'
|
||||
|
||||
// DataApiError class and factory
|
||||
export {
|
||||
DataApiError,
|
||||
DataApiErrorFactory,
|
||||
isDataApiError,
|
||||
isSerializedDataApiError,
|
||||
toDataApiError
|
||||
} from './errorCodes'
|
||||
} from './apiErrors'
|
||||
|
||||
// Error-related types
|
||||
export type {
|
||||
ConcurrentModificationErrorDetails,
|
||||
DatabaseErrorDetails,
|
||||
DataInconsistentErrorDetails,
|
||||
DetailsForCode,
|
||||
ErrorDetailsMap,
|
||||
InternalErrorDetails,
|
||||
NotFoundErrorDetails,
|
||||
PermissionDeniedErrorDetails,
|
||||
RequestContext,
|
||||
ResourceLockedErrorDetails,
|
||||
SerializedDataApiError,
|
||||
TimeoutErrorDetails,
|
||||
ValidationErrorDetails
|
||||
} from './apiErrors'
|
||||
|
||||
// ============================================================================
|
||||
// Subscription & Middleware (for advanced usage)
|
||||
// ============================================================================
|
||||
|
||||
export type {
|
||||
Middleware,
|
||||
RequestContext,
|
||||
ServiceOptions,
|
||||
SubscriptionCallback,
|
||||
SubscriptionOptions
|
||||
} from './apiTypes'
|
||||
export type { Middleware, ServiceOptions, SubscriptionCallback, SubscriptionOptions } from './apiTypes'
|
||||
export { SubscriptionEvent } from './apiTypes'
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { RequestContext as ErrorRequestContext } from '@shared/data/api/apiErrors'
|
||||
import { DataApiError, DataApiErrorFactory, toDataApiError } from '@shared/data/api/apiErrors'
|
||||
import type { ApiImplementation } from '@shared/data/api/apiTypes'
|
||||
import type { DataRequest, DataResponse, HttpMethod, RequestContext } from '@shared/data/api/apiTypes'
|
||||
import { DataApiErrorFactory, ErrorCode } from '@shared/data/api/errorCodes'
|
||||
|
||||
import { MiddlewareEngine } from './MiddlewareEngine'
|
||||
|
||||
@ -59,6 +60,14 @@ export class ApiServer {
|
||||
const { method, path } = request
|
||||
const startTime = Date.now()
|
||||
|
||||
// Build error request context for tracking
|
||||
const errorContext: ErrorRequestContext = {
|
||||
requestId: request.id,
|
||||
path,
|
||||
method: method as HttpMethod,
|
||||
timestamp: startTime
|
||||
}
|
||||
|
||||
logger.debug(`Processing request: ${method} ${path}`)
|
||||
|
||||
try {
|
||||
@ -66,7 +75,7 @@ export class ApiServer {
|
||||
const handlerMatch = this.findHandler(path, method as HttpMethod)
|
||||
|
||||
if (!handlerMatch) {
|
||||
throw DataApiErrorFactory.create(ErrorCode.NOT_FOUND, `Handler not found: ${method} ${path}`)
|
||||
throw DataApiErrorFactory.notFound('Handler', `${method} ${path}`, errorContext)
|
||||
}
|
||||
|
||||
// Create request context
|
||||
@ -91,12 +100,13 @@ export class ApiServer {
|
||||
} catch (error) {
|
||||
logger.error(`Request handling failed: ${method} ${path}`, error as Error)
|
||||
|
||||
const apiError = DataApiErrorFactory.create(ErrorCode.INTERNAL_SERVER_ERROR, (error as Error).message)
|
||||
// Convert to DataApiError and serialize for IPC
|
||||
const apiError = error instanceof DataApiError ? error : toDataApiError(error, `${method} ${path}`)
|
||||
|
||||
return {
|
||||
id: request.id,
|
||||
status: apiError.status,
|
||||
error: apiError,
|
||||
error: apiError.toJSON(), // Serialize for IPC transmission
|
||||
metadata: {
|
||||
duration: Date.now() - startTime,
|
||||
timestamp: Date.now()
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { toDataApiError } from '@shared/data/api/apiErrors'
|
||||
import type { DataRequest, DataResponse, Middleware, RequestContext } from '@shared/data/api/apiTypes'
|
||||
import { toDataApiError } from '@shared/data/api/errorCodes'
|
||||
|
||||
const logger = loggerService.withContext('MiddlewareEngine')
|
||||
|
||||
@ -82,7 +82,7 @@ export class MiddlewareEngine {
|
||||
logger.error(`Request error: ${req.method} ${req.path}`, error as Error)
|
||||
|
||||
const apiError = toDataApiError(error, `${req.method} ${req.path}`)
|
||||
res.error = apiError
|
||||
res.error = apiError.toJSON() // Serialize for IPC transmission
|
||||
res.status = apiError.status
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { toDataApiError } from '@shared/data/api/apiErrors'
|
||||
import type { DataRequest, DataResponse } from '@shared/data/api/apiTypes'
|
||||
import { toDataApiError } from '@shared/data/api/errorCodes'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
@ -46,7 +46,7 @@ export class IpcAdapter {
|
||||
const errorResponse: DataResponse = {
|
||||
id: request.id,
|
||||
status: apiError.status,
|
||||
error: apiError,
|
||||
error: apiError.toJSON(), // Serialize for IPC transmission
|
||||
metadata: {
|
||||
duration: 0,
|
||||
timestamp: Date.now()
|
||||
|
||||
@ -30,6 +30,8 @@
|
||||
*/
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import type { RequestContext } from '@shared/data/api/apiErrors'
|
||||
import { DataApiError, DataApiErrorFactory, ErrorCode, toDataApiError } from '@shared/data/api/apiErrors'
|
||||
import type { ApiClient, ConcreteApiPaths } from '@shared/data/api/apiTypes'
|
||||
import type {
|
||||
DataRequest,
|
||||
@ -38,18 +40,20 @@ import type {
|
||||
SubscriptionEvent,
|
||||
SubscriptionOptions
|
||||
} from '@shared/data/api/apiTypes'
|
||||
import { toDataApiError } from '@shared/data/api/errorCodes'
|
||||
|
||||
const logger = loggerService.withContext('DataApiService')
|
||||
|
||||
/**
|
||||
* Retry options interface
|
||||
* Retry options interface.
|
||||
* Retryability is now determined by DataApiError.isRetryable getter.
|
||||
*/
|
||||
interface RetryOptions {
|
||||
/** Maximum number of retry attempts */
|
||||
maxRetries: number
|
||||
/** Initial delay between retries in milliseconds */
|
||||
retryDelay: number
|
||||
/** Multiplier for exponential backoff */
|
||||
backoffMultiplier: number
|
||||
retryCondition: (error: Error) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@ -71,22 +75,11 @@ export class DataApiService implements ApiClient {
|
||||
>()
|
||||
|
||||
// Default retry options
|
||||
// Retryability is determined by DataApiError.isRetryable
|
||||
private defaultRetryOptions: RetryOptions = {
|
||||
maxRetries: 2,
|
||||
retryDelay: 1000,
|
||||
backoffMultiplier: 2,
|
||||
retryCondition: (error: Error) => {
|
||||
// Retry on network errors or temporary failures
|
||||
const message = error.message.toLowerCase()
|
||||
return (
|
||||
message.includes('timeout') ||
|
||||
message.includes('network') ||
|
||||
message.includes('connection') ||
|
||||
message.includes('unavailable') ||
|
||||
message.includes('500') ||
|
||||
message.includes('503')
|
||||
)
|
||||
}
|
||||
backoffMultiplier: 2
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
@ -131,11 +124,20 @@ export class DataApiService implements ApiClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send request via IPC with direct return and retry logic
|
||||
* Send request via IPC with direct return and retry logic.
|
||||
* Uses DataApiError.isRetryable to determine if retry is appropriate.
|
||||
*/
|
||||
private async sendRequest<T>(request: DataRequest, retryCount = 0): Promise<T> {
|
||||
if (!window.api.dataApi.request) {
|
||||
throw new Error('Data API not available')
|
||||
throw DataApiErrorFactory.create(ErrorCode.SERVICE_UNAVAILABLE, 'Data API not available')
|
||||
}
|
||||
|
||||
// Build request context for error tracking
|
||||
const requestContext: RequestContext = {
|
||||
requestId: request.id,
|
||||
path: request.path,
|
||||
method: request.method as HttpMethod,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
try {
|
||||
@ -144,11 +146,14 @@ export class DataApiService implements ApiClient {
|
||||
// Direct IPC call with timeout
|
||||
const response = await Promise.race([
|
||||
window.api.dataApi.request(request),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`Request timeout: ${request.path}`)), 3000))
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(DataApiErrorFactory.timeout(request.path, 3000, requestContext)), 3000)
|
||||
)
|
||||
])
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message)
|
||||
// Reconstruct DataApiError from serialized response
|
||||
throw DataApiError.fromJSON(response.error)
|
||||
}
|
||||
|
||||
logger.debug(`Request succeeded: ${request.method} ${request.path}`, {
|
||||
@ -158,14 +163,17 @@ export class DataApiService implements ApiClient {
|
||||
|
||||
return response.data as T
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.debug(`Request failed: ${request.method} ${request.path}`, error as Error)
|
||||
// Ensure we have a DataApiError for consistent handling
|
||||
const apiError =
|
||||
error instanceof DataApiError ? error : toDataApiError(error, `${request.method} ${request.path}`)
|
||||
|
||||
// Check if should retry
|
||||
if (retryCount < this.defaultRetryOptions.maxRetries && this.defaultRetryOptions.retryCondition(error as Error)) {
|
||||
logger.debug(`Request failed: ${request.method} ${request.path}`, apiError)
|
||||
|
||||
// Check if should retry using the error's built-in isRetryable getter
|
||||
if (retryCount < this.defaultRetryOptions.maxRetries && apiError.isRetryable) {
|
||||
logger.debug(
|
||||
`Retrying request attempt ${retryCount + 1}/${this.defaultRetryOptions.maxRetries}: ${request.path}`,
|
||||
{ error: errorMessage }
|
||||
{ error: apiError.message, code: apiError.code }
|
||||
)
|
||||
|
||||
// Calculate delay with exponential backoff
|
||||
@ -179,7 +187,7 @@ export class DataApiService implements ApiClient {
|
||||
return this.sendRequest<T>(retryRequest, retryCount + 1)
|
||||
}
|
||||
|
||||
throw error
|
||||
throw apiError
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user