From 172906e0d4ca22cd7c622e4231bf92ff920b058c Mon Sep 17 00:00:00 2001 From: Emanuele Santonastaso Date: Wed, 1 Apr 2026 00:57:55 +0200 Subject: [PATCH] feat(resilience): add structured error categorization and retry logic (#701) - ErrorCode enum: TIMEOUT, AUTH, VALIDATION, PIPELINE, TMUX, INTERNAL, RATE_LIMIT - categorize(error) returns {code, retryable, category} - shouldRetry(error) helper for retry decisions Fixes #701 --- src/__tests__/error-categories.test.ts | 161 +++++++++++++++++++++++++ src/error-categories.ts | 87 +++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 src/__tests__/error-categories.test.ts create mode 100644 src/error-categories.ts diff --git a/src/__tests__/error-categories.test.ts b/src/__tests__/error-categories.test.ts new file mode 100644 index 00000000..92b797ac --- /dev/null +++ b/src/__tests__/error-categories.test.ts @@ -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'); + }); +}); diff --git a/src/error-categories.ts b/src/error-categories.ts new file mode 100644 index 00000000..44330e4a --- /dev/null +++ b/src/error-categories.ts @@ -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; +}