diff --git a/packages/shared/data/api/README.md b/packages/shared/data/api/README.md index bd45b67aa2..452b3a22c0 100644 --- a/packages/shared/data/api/README.md +++ b/packages/shared/data/api/README.md @@ -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 ``` diff --git a/packages/shared/data/api/api-design-guidelines.md b/packages/shared/data/api/api-design-guidelines.md index 0cd8ccbe8b..d508260aa2 100644 --- a/packages/shared/data/api/api-design-guidelines.md +++ b/packages/shared/data/api/api-design-guidelines.md @@ -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 // 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 diff --git a/packages/shared/data/api/apiErrors.ts b/packages/shared/data/api/apiErrors.ts new file mode 100644 index 0000000000..71d710a2f4 --- /dev/null +++ b/packages/shared/data/api/apiErrors.ts @@ -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 = { + // 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.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 +} + +/** + * 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 keyof ErrorDetailsMap + ? ErrorDetailsMap[T] + : Record | 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 = 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 + /** 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 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 + /** Request context for debugging */ + public readonly requestContext?: RequestContext + + constructor(code: T, message: string, status: number, details?: DetailsForCode, 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 | 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, + 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( + code: T, + customMessage?: string, + details?: DetailsForCode, + requestContext?: RequestContext + ): DataApiError { + 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, + message?: string, + requestContext?: RequestContext + ): DataApiError { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + ) +} diff --git a/packages/shared/data/api/apiTypes.ts b/packages/shared/data/api/apiTypes.ts index e89a769619..207703e46a 100644 --- a/packages/shared/data/api/apiTypes.ts +++ b/packages/shared/data/api/apiTypes.ts @@ -113,7 +113,7 @@ export interface DataResponse { /** 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 { } } -/** - * 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 diff --git a/packages/shared/data/api/errorCodes.ts b/packages/shared/data/api/errorCodes.ts deleted file mode 100644 index 7ccb96c8c9..0000000000 --- a/packages/shared/data/api/errorCodes.ts +++ /dev/null @@ -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 = { - // 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.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, 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 } - ) -} diff --git a/packages/shared/data/api/index.ts b/packages/shared/data/api/index.ts index f4404af011..bb04eff0fb 100644 --- a/packages/shared/data/api/index.ts +++ b/packages/shared/data/api/index.ts @@ -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' diff --git a/src/main/data/api/core/ApiServer.ts b/src/main/data/api/core/ApiServer.ts index 57fe86add5..df92c783b9 100644 --- a/src/main/data/api/core/ApiServer.ts +++ b/src/main/data/api/core/ApiServer.ts @@ -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() diff --git a/src/main/data/api/core/MiddlewareEngine.ts b/src/main/data/api/core/MiddlewareEngine.ts index 1f6bf1915d..d8af7bd3ef 100644 --- a/src/main/data/api/core/MiddlewareEngine.ts +++ b/src/main/data/api/core/MiddlewareEngine.ts @@ -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 } } diff --git a/src/main/data/api/core/adapters/IpcAdapter.ts b/src/main/data/api/core/adapters/IpcAdapter.ts index a37a33cbd3..b4d8e56bfe 100644 --- a/src/main/data/api/core/adapters/IpcAdapter.ts +++ b/src/main/data/api/core/adapters/IpcAdapter.ts @@ -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() diff --git a/src/renderer/src/data/DataApiService.ts b/src/renderer/src/data/DataApiService.ts index 35a7fdac3b..8b2b8a7f62 100644 --- a/src/renderer/src/data/DataApiService.ts +++ b/src/renderer/src/data/DataApiService.ts @@ -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(request: DataRequest, retryCount = 0): Promise { 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((_, reject) => setTimeout(() => reject(new Error(`Request timeout: ${request.path}`)), 3000)) + new Promise((_, 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(retryRequest, retryCount + 1) } - throw error + throw apiError } }