feat(shared): add error handling infrastructure (#27)
- Add JakartaError base class with error codes and retry semantics - Add typed error classes for network, session, compaction, process, channel, and gateway errors - Add withRetry utility with exponential backoff, jitter, and abort support - Add CancellationToken with hierarchical parent-child cancellation support Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6f8c9ef383
commit
f74ac430a9
4 changed files with 880 additions and 0 deletions
353
src/shared/errors.ts
Normal file
353
src/shared/errors.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
/**
|
||||
* Error type hierarchy for Multica agent system.
|
||||
* Provides typed errors with retry semantics and serialization support.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base error class for all Multica errors.
|
||||
* Provides common functionality like error codes, retry semantics, and JSON serialization.
|
||||
*/
|
||||
export abstract class MulticaError extends Error {
|
||||
/** Unique error code for programmatic handling */
|
||||
abstract readonly code: string;
|
||||
|
||||
/** Whether this error type is generally retryable */
|
||||
abstract readonly retryable: boolean;
|
||||
|
||||
/** Timestamp when the error occurred */
|
||||
readonly timestamp = Date.now();
|
||||
|
||||
/** Additional context about the error */
|
||||
readonly details: Record<string, unknown> | undefined;
|
||||
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
this.details = details;
|
||||
|
||||
// Maintains proper stack trace for where error was thrown (V8 only)
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize error for logging or transmission
|
||||
*/
|
||||
toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
name: this.name,
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
retryable: this.retryable,
|
||||
timestamp: this.timestamp,
|
||||
details: this.details,
|
||||
stack: this.stack,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Network / API Errors
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* General network connectivity error (DNS, TCP, TLS failures)
|
||||
*/
|
||||
export class NetworkError extends MulticaError {
|
||||
readonly code = "NETWORK_ERROR" as const;
|
||||
readonly retryable = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming connection was unexpectedly disconnected
|
||||
*/
|
||||
export class StreamDisconnectedError extends MulticaError {
|
||||
readonly code = "STREAM_DISCONNECTED" as const;
|
||||
readonly retryable = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* API rate limit exceeded
|
||||
*/
|
||||
export class RateLimitError extends MulticaError {
|
||||
readonly code = "RATE_LIMIT" as const;
|
||||
readonly retryable = true;
|
||||
|
||||
/** Seconds to wait before retrying (from Retry-After header) */
|
||||
readonly retryAfter: number | undefined;
|
||||
|
||||
constructor(message: string, retryAfter?: number, details?: Record<string, unknown>) {
|
||||
super(message, { ...details, retryAfter });
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API returned an error response
|
||||
*/
|
||||
export class APIError extends MulticaError {
|
||||
readonly code = "API_ERROR" as const;
|
||||
|
||||
/** HTTP status code if available */
|
||||
readonly statusCode: number | undefined;
|
||||
|
||||
/** Whether this specific API error is retryable */
|
||||
readonly retryable: boolean;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
statusCode?: number,
|
||||
retryable = false,
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message, { ...details, statusCode });
|
||||
this.statusCode = statusCode;
|
||||
this.retryable = retryable;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Session / Storage Errors
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Failed to read or write session data
|
||||
*/
|
||||
export class SessionStorageError extends MulticaError {
|
||||
readonly code = "SESSION_STORAGE_ERROR" as const;
|
||||
readonly retryable = false;
|
||||
|
||||
/** The session ID that failed */
|
||||
readonly sessionId: string | undefined;
|
||||
|
||||
constructor(message: string, sessionId?: string, details?: Record<string, unknown>) {
|
||||
super(message, { ...details, sessionId });
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session data is corrupted or partially unreadable
|
||||
*/
|
||||
export class SessionCorruptedError extends MulticaError {
|
||||
readonly code = "SESSION_CORRUPTED" as const;
|
||||
readonly retryable = false;
|
||||
|
||||
/** Number of entries that were successfully recovered */
|
||||
readonly recoveredEntries: number | undefined;
|
||||
|
||||
/** Number of entries that were corrupted */
|
||||
readonly corruptedEntries: number | undefined;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
recoveredEntries?: number,
|
||||
corruptedEntries?: number,
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message, { ...details, recoveredEntries, corruptedEntries });
|
||||
this.recoveredEntries = recoveredEntries;
|
||||
this.corruptedEntries = corruptedEntries;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Compaction Errors
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* General compaction operation failure
|
||||
*/
|
||||
export class CompactionError extends MulticaError {
|
||||
readonly code = "COMPACTION_ERROR" as const;
|
||||
readonly retryable = true;
|
||||
|
||||
/** Compaction mode that failed */
|
||||
readonly mode: string | undefined;
|
||||
|
||||
constructor(message: string, mode?: string, details?: Record<string, unknown>) {
|
||||
super(message, { ...details, mode });
|
||||
this.mode = mode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Failed to generate conversation summary for compaction
|
||||
*/
|
||||
export class SummaryGenerationError extends MulticaError {
|
||||
readonly code = "SUMMARY_GENERATION_ERROR" as const;
|
||||
readonly retryable = true;
|
||||
|
||||
/** Number of messages that were being summarized */
|
||||
readonly messagesCount: number | undefined;
|
||||
|
||||
constructor(message: string, messagesCount?: number, details?: Record<string, unknown>) {
|
||||
super(message, { ...details, messagesCount });
|
||||
this.messagesCount = messagesCount;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Process Execution Errors
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Process execution exceeded timeout
|
||||
*/
|
||||
export class ProcessTimeoutError extends MulticaError {
|
||||
readonly code = "PROCESS_TIMEOUT" as const;
|
||||
readonly retryable = false;
|
||||
|
||||
/** Timeout in milliseconds */
|
||||
readonly timeoutMs: number | undefined;
|
||||
|
||||
/** Process ID if available */
|
||||
readonly pid: number | undefined;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
timeoutMs?: number,
|
||||
pid?: number,
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message, { ...details, timeoutMs, pid });
|
||||
this.timeoutMs = timeoutMs;
|
||||
this.pid = pid;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process was killed by signal
|
||||
*/
|
||||
export class ProcessKilledError extends MulticaError {
|
||||
readonly code = "PROCESS_KILLED" as const;
|
||||
readonly retryable = false;
|
||||
|
||||
/** Signal that killed the process */
|
||||
readonly signal: string | undefined;
|
||||
|
||||
/** Process ID if available */
|
||||
readonly pid: number | undefined;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
signal?: string,
|
||||
pid?: number,
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message, { ...details, signal, pid });
|
||||
this.signal = signal;
|
||||
this.pid = pid;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Channel Errors
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Attempted to use a closed channel
|
||||
*/
|
||||
export class ChannelClosedError extends MulticaError {
|
||||
readonly code = "CHANNEL_CLOSED" as const;
|
||||
readonly retryable = false;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hub / Gateway Errors
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Failed to connect to gateway
|
||||
*/
|
||||
export class GatewayConnectionError extends MulticaError {
|
||||
readonly code = "GATEWAY_CONNECTION_ERROR" as const;
|
||||
readonly retryable = true;
|
||||
|
||||
/** Gateway URL that failed */
|
||||
readonly url: string | undefined;
|
||||
|
||||
constructor(message: string, url?: string, details?: Record<string, unknown>) {
|
||||
super(message, { ...details, url });
|
||||
this.url = url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Failed to deliver message through gateway
|
||||
*/
|
||||
export class MessageDeliveryError extends MulticaError {
|
||||
readonly code = "MESSAGE_DELIVERY_ERROR" as const;
|
||||
readonly retryable = true;
|
||||
|
||||
/** ID of the message that failed */
|
||||
readonly messageId: string | undefined;
|
||||
|
||||
/** Target device ID */
|
||||
readonly targetDeviceId: string | undefined;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
messageId?: string,
|
||||
targetDeviceId?: string,
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message, { ...details, messageId, targetDeviceId });
|
||||
this.messageId = messageId;
|
||||
this.targetDeviceId = targetDeviceId;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Type Guards
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if an error is a MulticaError
|
||||
*/
|
||||
export function isMulticaError(error: unknown): error is MulticaError {
|
||||
return error instanceof MulticaError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is retryable
|
||||
*/
|
||||
export function isRetryableError(error: unknown): boolean {
|
||||
if (error instanceof MulticaError) {
|
||||
return error.retryable;
|
||||
}
|
||||
|
||||
// Check for common transient error patterns
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
message.includes("network") ||
|
||||
message.includes("timeout") ||
|
||||
message.includes("econnreset") ||
|
||||
message.includes("econnrefused") ||
|
||||
message.includes("socket hang up") ||
|
||||
message.includes("fetch failed")
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* All Jakarta error codes for type-safe handling
|
||||
*/
|
||||
export type MulticaErrorCode =
|
||||
| "NETWORK_ERROR"
|
||||
| "STREAM_DISCONNECTED"
|
||||
| "RATE_LIMIT"
|
||||
| "API_ERROR"
|
||||
| "SESSION_STORAGE_ERROR"
|
||||
| "SESSION_CORRUPTED"
|
||||
| "COMPACTION_ERROR"
|
||||
| "SUMMARY_GENERATION_ERROR"
|
||||
| "PROCESS_TIMEOUT"
|
||||
| "PROCESS_KILLED"
|
||||
| "CHANNEL_CLOSED"
|
||||
| "GATEWAY_CONNECTION_ERROR"
|
||||
| "MESSAGE_DELIVERY_ERROR";
|
||||
Loading…
Add table
Add a link
Reference in a new issue