Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions src/__tests__/error-categories.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* error-categories.test.ts — Tests for ErrorCode enum, categorize(), and shouldRetry().
*
* Issue #701.
*/

import { describe, it, expect } from 'vitest';
import { ErrorCode, categorize, shouldRetry } from '../error-categories.js';
import { TmuxTimeoutError } from '../tmux.js';

// ── categorize() ──────────────────────────────────────────────────

describe('categorize', () => {
it('categorizes TmuxTimeoutError', () => {
const err = new TmuxTimeoutError(['send-keys', 'hello'], 5000);
const result = categorize(err);
expect(result.code).toBe(ErrorCode.TMUX_TIMEOUT);
expect(result.retryable).toBe(true);
expect(result.message).toContain('timed out');
});

it('categorizes session-not-found errors', () => {
const cases = [
new Error('Session not found: abc-123'),
new Error('No session with id xyz'),
];
for (const err of cases) {
const result = categorize(err);
expect(result.code).toBe(ErrorCode.SESSION_NOT_FOUND);
expect(result.retryable).toBe(false);
}
});

it('categorizes permission-rejected errors', () => {
const cases = [
new Error('Permission denied: write to /etc/passwd'),
new Error('Permission rejected by user'),
];
for (const err of cases) {
const result = categorize(err);
expect(result.code).toBe(ErrorCode.PERMISSION_REJECTED);
expect(result.retryable).toBe(false);
}
});

it('categorizes auth errors', () => {
const cases = [
new Error('Unauthorized: missing token'),
new Error('Invalid token'),
new Error('Authentication failed'),
];
for (const err of cases) {
const result = categorize(err);
expect(result.code).toBe(ErrorCode.AUTH_ERROR);
expect(result.retryable).toBe(false);
}
});

it('categorizes rate-limit errors', () => {
const cases = [
new Error('Rate limit exceeded'),
new Error('Too many requests'),
];
for (const err of cases) {
const result = categorize(err);
expect(result.code).toBe(ErrorCode.RATE_LIMITED);
expect(result.retryable).toBe(true);
}
});

it('categorizes validation errors', () => {
const cases = [
new Error('Validation failed: text is required'),
new Error('Invalid session ID format'),
new Error('text is required'),
];
for (const err of cases) {
const result = categorize(err);
expect(result.code).toBe(ErrorCode.VALIDATION_ERROR);
expect(result.retryable).toBe(false);
}
});

it('categorizes network errors', () => {
const cases = [
new Error('ECONNREFUSED 127.0.0.1:9100'),
new Error('ECONNRESET'),
new Error('ETIMEDOUT after 30000ms'),
new Error('fetch failed'),
];
for (const err of cases) {
const result = categorize(err);
expect(result.code).toBe(ErrorCode.NETWORK_ERROR);
expect(result.retryable).toBe(true);
}
});

it('categorizes generic tmux errors', () => {
const err = new Error('tmux create-window failed');
const result = categorize(err);
expect(result.code).toBe(ErrorCode.TMUX_ERROR);
expect(result.retryable).toBe(true);
});

it('falls back to INTERNAL_ERROR for unknown Errors', () => {
const err = new Error('something unexpected');
const result = categorize(err);
expect(result.code).toBe(ErrorCode.INTERNAL_ERROR);
expect(result.retryable).toBe(false);
expect(result.message).toBe('something unexpected');
});

it('handles string errors', () => {
const result = categorize('plain string error');
expect(result.code).toBe(ErrorCode.INTERNAL_ERROR);
expect(result.message).toBe('plain string error');
});

it('handles non-string, non-Error values', () => {
expect(categorize(42).message).toBe('42');
expect(categorize(null).message).toBe('null');
expect(categorize(undefined).message).toBe('undefined');
});
});

// ── shouldRetry() ─────────────────────────────────────────────────

describe('shouldRetry', () => {
it('returns true for retryable errors', () => {
expect(shouldRetry(new TmuxTimeoutError(['list-sessions'], 5000))).toBe(true);
expect(shouldRetry(new Error('Rate limit exceeded'))).toBe(true);
expect(shouldRetry(new Error('ECONNREFUSED'))).toBe(true);
expect(shouldRetry(new Error('tmux send-keys failed'))).toBe(true);
});

it('returns false for non-retryable errors', () => {
expect(shouldRetry(new Error('Session not found'))).toBe(false);
expect(shouldRetry(new Error('Permission denied'))).toBe(false);
expect(shouldRetry(new Error('Unauthorized'))).toBe(false);
expect(shouldRetry(new Error('Validation failed'))).toBe(false);
expect(shouldRetry(new Error('something unexpected'))).toBe(false);
expect(shouldRetry('string error')).toBe(false);
});
});

// ── ErrorCode enum ────────────────────────────────────────────────

describe('ErrorCode enum', () => {
it('has expected members', () => {
expect(ErrorCode.SESSION_NOT_FOUND).toBe('SESSION_NOT_FOUND');
expect(ErrorCode.SESSION_CREATE_FAILED).toBe('SESSION_CREATE_FAILED');
expect(ErrorCode.PERMISSION_REJECTED).toBe('PERMISSION_REJECTED');
expect(ErrorCode.TMUX_TIMEOUT).toBe('TMUX_TIMEOUT');
expect(ErrorCode.TMUX_ERROR).toBe('TMUX_ERROR');
expect(ErrorCode.VALIDATION_ERROR).toBe('VALIDATION_ERROR');
expect(ErrorCode.AUTH_ERROR).toBe('AUTH_ERROR');
expect(ErrorCode.RATE_LIMITED).toBe('RATE_LIMITED');
expect(ErrorCode.NETWORK_ERROR).toBe('NETWORK_ERROR');
expect(ErrorCode.INTERNAL_ERROR).toBe('INTERNAL_ERROR');
});
});
87 changes: 87 additions & 0 deletions src/error-categories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* error-categories.ts — Structured error categorization and retry guidance.
*
* Issue #701: Provides an ErrorCode enum, categorize() function to inspect
* unknown errors and return structured metadata, and shouldRetry() helper.
*/

import { TmuxTimeoutError } from './tmux.js';

/** String enum of Aegis error codes. */
export enum ErrorCode {
/** Session not found, already deleted, or in wrong state. */
SESSION_NOT_FOUND = 'SESSION_NOT_FOUND',
/** Session creation failed (tmux window, CC launch). */
SESSION_CREATE_FAILED = 'SESSION_CREATE_FAILED',
/** Permission request was rejected by the user. */
PERMISSION_REJECTED = 'PERMISSION_REJECTED',
/** Tmux command timed out. */
TMUX_TIMEOUT = 'TMUX_TIMEOUT',
/** Tmux operation failed (non-timeout). */
TMUX_ERROR = 'TMUX_ERROR',
/** Request body or parameter failed validation. */
VALIDATION_ERROR = 'VALIDATION_ERROR',
/** Authentication failed (missing/invalid token). */
AUTH_ERROR = 'AUTH_ERROR',
/** Rate limit exceeded. */
RATE_LIMITED = 'RATE_LIMITED',
/** Network or I/O error (transient). */
NETWORK_ERROR = 'NETWORK_ERROR',
/** Unexpected internal error. */
INTERNAL_ERROR = 'INTERNAL_ERROR',
}

/** Structured result returned by categorize(). */
export interface CategorizedError {
code: ErrorCode;
message: string;
retryable: boolean;
}

/** Inspect an unknown error and return a structured categorization. */
export function categorize(error: unknown): CategorizedError {
// 1. Known typed errors
if (error instanceof TmuxTimeoutError) {
return { code: ErrorCode.TMUX_TIMEOUT, message: error.message, retryable: true };
}

if (error instanceof Error) {
const msg = error.message;
const lower = msg.toLowerCase();

// 2. Message-based heuristics for common Aegis error patterns
if (lower.includes('session not found') || lower.includes('no session with id')) {
return { code: ErrorCode.SESSION_NOT_FOUND, message: msg, retryable: false };
}
if (lower.includes('permission denied') || lower.includes('permission rejected')) {
return { code: ErrorCode.PERMISSION_REJECTED, message: msg, retryable: false };
}
if (lower.includes('unauthorized') || lower.includes('invalid token') || lower.includes('authentication')) {
return { code: ErrorCode.AUTH_ERROR, message: msg, retryable: false };
}
if (lower.includes('rate limit') || lower.includes('too many requests')) {
return { code: ErrorCode.RATE_LIMITED, message: msg, retryable: true };
}
if (lower.includes('validation') || lower.includes('invalid ') || lower.includes('required')) {
return { code: ErrorCode.VALIDATION_ERROR, message: msg, retryable: false };
}
if (lower.includes('econnrefused') || lower.includes('econnreset') || lower.includes('etimedout') || lower.includes('fetch failed')) {
return { code: ErrorCode.NETWORK_ERROR, message: msg, retryable: true };
}
if (lower.includes('tmux')) {
return { code: ErrorCode.TMUX_ERROR, message: msg, retryable: true };
}

// 3. Generic Error fallback
return { code: ErrorCode.INTERNAL_ERROR, message: msg, retryable: false };
}

// 4. Non-Error values
const msg = typeof error === 'string' ? error : String(error);
return { code: ErrorCode.INTERNAL_ERROR, message: msg, retryable: false };
}

/** Return true if the error is worth retrying. */
export function shouldRetry(error: unknown): boolean {
return categorize(error).retryable;
}