319 lines
9.4 KiB
TypeScript
319 lines
9.4 KiB
TypeScript
|
|
/**
|
||
|
|
* AI Error Classification Service
|
||
|
|
*
|
||
|
|
* Provides unified error handling and classification for all AI services.
|
||
|
|
* Converts technical API errors into user-friendly messages.
|
||
|
|
*/
|
||
|
|
|
||
|
|
// ─── Error Types ─────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
export type AIErrorType =
|
||
|
|
| 'rate_limit'
|
||
|
|
| 'quota_exceeded'
|
||
|
|
| 'model_not_found'
|
||
|
|
| 'invalid_api_key'
|
||
|
|
| 'context_length'
|
||
|
|
| 'parse_error'
|
||
|
|
| 'timeout'
|
||
|
|
| 'network_error'
|
||
|
|
| 'content_filter'
|
||
|
|
| 'server_error'
|
||
|
|
| 'unknown'
|
||
|
|
|
||
|
|
export interface ClassifiedError {
|
||
|
|
type: AIErrorType
|
||
|
|
message: string
|
||
|
|
originalMessage: string
|
||
|
|
retryable: boolean
|
||
|
|
suggestedAction?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Error Patterns ──────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
interface ErrorPattern {
|
||
|
|
type: AIErrorType
|
||
|
|
patterns: Array<string | RegExp>
|
||
|
|
retryable: boolean
|
||
|
|
userMessage: string
|
||
|
|
suggestedAction?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
const ERROR_PATTERNS: ErrorPattern[] = [
|
||
|
|
{
|
||
|
|
type: 'rate_limit',
|
||
|
|
patterns: [
|
||
|
|
'rate_limit',
|
||
|
|
'rate limit',
|
||
|
|
'too many requests',
|
||
|
|
'429',
|
||
|
|
'quota exceeded',
|
||
|
|
'Rate limit reached',
|
||
|
|
],
|
||
|
|
retryable: true,
|
||
|
|
userMessage: 'Rate limit exceeded. Please wait a few minutes and try again.',
|
||
|
|
suggestedAction: 'Wait 1-2 minutes before retrying, or reduce batch size.',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
type: 'quota_exceeded',
|
||
|
|
patterns: [
|
||
|
|
'insufficient_quota',
|
||
|
|
'billing',
|
||
|
|
'exceeded your current quota',
|
||
|
|
'payment required',
|
||
|
|
'account deactivated',
|
||
|
|
],
|
||
|
|
retryable: false,
|
||
|
|
userMessage: 'API quota exceeded. Please check your OpenAI billing settings.',
|
||
|
|
suggestedAction: 'Add payment method or increase spending limit in OpenAI dashboard.',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
type: 'model_not_found',
|
||
|
|
patterns: [
|
||
|
|
'model_not_found',
|
||
|
|
'does not exist',
|
||
|
|
'The model',
|
||
|
|
'invalid model',
|
||
|
|
'model not available',
|
||
|
|
],
|
||
|
|
retryable: false,
|
||
|
|
userMessage: 'The selected AI model is not available. Please check your settings.',
|
||
|
|
suggestedAction: 'Go to Settings → AI and select a different model.',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
type: 'invalid_api_key',
|
||
|
|
patterns: [
|
||
|
|
'invalid_api_key',
|
||
|
|
'Incorrect API key',
|
||
|
|
'authentication',
|
||
|
|
'unauthorized',
|
||
|
|
'401',
|
||
|
|
'invalid api key',
|
||
|
|
],
|
||
|
|
retryable: false,
|
||
|
|
userMessage: 'Invalid API key. Please check your OpenAI API key in settings.',
|
||
|
|
suggestedAction: 'Go to Settings → AI and enter a valid API key.',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
type: 'context_length',
|
||
|
|
patterns: [
|
||
|
|
'context_length',
|
||
|
|
'maximum context length',
|
||
|
|
'tokens',
|
||
|
|
'too long',
|
||
|
|
'reduce the length',
|
||
|
|
'max_tokens',
|
||
|
|
],
|
||
|
|
retryable: true,
|
||
|
|
userMessage: 'Request too large. Try processing fewer items at once.',
|
||
|
|
suggestedAction: 'Process items in smaller batches.',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
type: 'content_filter',
|
||
|
|
patterns: [
|
||
|
|
'content_filter',
|
||
|
|
'content policy',
|
||
|
|
'flagged',
|
||
|
|
'inappropriate',
|
||
|
|
'safety system',
|
||
|
|
],
|
||
|
|
retryable: false,
|
||
|
|
userMessage: 'Content was flagged by the AI safety system. Please review the input data.',
|
||
|
|
suggestedAction: 'Check project descriptions for potentially sensitive content.',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
type: 'timeout',
|
||
|
|
patterns: [
|
||
|
|
'timeout',
|
||
|
|
'timed out',
|
||
|
|
'ETIMEDOUT',
|
||
|
|
'ECONNABORTED',
|
||
|
|
'deadline exceeded',
|
||
|
|
],
|
||
|
|
retryable: true,
|
||
|
|
userMessage: 'Request timed out. Please try again.',
|
||
|
|
suggestedAction: 'Try again or process fewer items at once.',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
type: 'network_error',
|
||
|
|
patterns: [
|
||
|
|
'ENOTFOUND',
|
||
|
|
'ECONNREFUSED',
|
||
|
|
'network',
|
||
|
|
'connection',
|
||
|
|
'DNS',
|
||
|
|
'getaddrinfo',
|
||
|
|
],
|
||
|
|
retryable: true,
|
||
|
|
userMessage: 'Network error. Please check your connection and try again.',
|
||
|
|
suggestedAction: 'Check network connectivity and firewall settings.',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
type: 'server_error',
|
||
|
|
patterns: [
|
||
|
|
'500',
|
||
|
|
'502',
|
||
|
|
'503',
|
||
|
|
'504',
|
||
|
|
'internal error',
|
||
|
|
'server error',
|
||
|
|
'service unavailable',
|
||
|
|
],
|
||
|
|
retryable: true,
|
||
|
|
userMessage: 'OpenAI service temporarily unavailable. Please try again later.',
|
||
|
|
suggestedAction: 'Wait a few minutes and retry. Check status.openai.com for outages.',
|
||
|
|
},
|
||
|
|
]
|
||
|
|
|
||
|
|
// ─── Error Classification ────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Classify an error from the OpenAI API
|
||
|
|
*/
|
||
|
|
export function classifyAIError(error: Error | unknown): ClassifiedError {
|
||
|
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||
|
|
const errorString = errorMessage.toLowerCase()
|
||
|
|
|
||
|
|
// Check against known patterns
|
||
|
|
for (const pattern of ERROR_PATTERNS) {
|
||
|
|
for (const matcher of pattern.patterns) {
|
||
|
|
const matches =
|
||
|
|
typeof matcher === 'string'
|
||
|
|
? errorString.includes(matcher.toLowerCase())
|
||
|
|
: matcher.test(errorString)
|
||
|
|
|
||
|
|
if (matches) {
|
||
|
|
return {
|
||
|
|
type: pattern.type,
|
||
|
|
message: pattern.userMessage,
|
||
|
|
originalMessage: errorMessage,
|
||
|
|
retryable: pattern.retryable,
|
||
|
|
suggestedAction: pattern.suggestedAction,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Unknown error
|
||
|
|
return {
|
||
|
|
type: 'unknown',
|
||
|
|
message: 'An unexpected error occurred. Please try again.',
|
||
|
|
originalMessage: errorMessage,
|
||
|
|
retryable: true,
|
||
|
|
suggestedAction: 'If the problem persists, check the AI settings or contact support.',
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if an error is a JSON parse error
|
||
|
|
*/
|
||
|
|
export function isParseError(error: Error | unknown): boolean {
|
||
|
|
const message = error instanceof Error ? error.message : String(error)
|
||
|
|
return (
|
||
|
|
message.includes('JSON') ||
|
||
|
|
message.includes('parse') ||
|
||
|
|
message.includes('Unexpected token') ||
|
||
|
|
message.includes('SyntaxError')
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create a classified parse error
|
||
|
|
*/
|
||
|
|
export function createParseError(originalMessage: string): ClassifiedError {
|
||
|
|
return {
|
||
|
|
type: 'parse_error',
|
||
|
|
message: 'AI returned an invalid response. Items flagged for manual review.',
|
||
|
|
originalMessage,
|
||
|
|
retryable: true,
|
||
|
|
suggestedAction: 'Review flagged items manually. Consider using a different model.',
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── User-Friendly Messages ──────────────────────────────────────────────────
|
||
|
|
|
||
|
|
const USER_FRIENDLY_MESSAGES: Record<AIErrorType, string> = {
|
||
|
|
rate_limit: 'Rate limit exceeded. Please wait a few minutes and try again.',
|
||
|
|
quota_exceeded: 'API quota exceeded. Please check your OpenAI billing settings.',
|
||
|
|
model_not_found: 'Selected AI model is not available. Please check your settings.',
|
||
|
|
invalid_api_key: 'Invalid API key. Please verify your OpenAI API key.',
|
||
|
|
context_length: 'Request too large. Please try with fewer items.',
|
||
|
|
parse_error: 'AI response could not be processed. Items flagged for review.',
|
||
|
|
timeout: 'Request timed out. Please try again.',
|
||
|
|
network_error: 'Network connection error. Please check your connection.',
|
||
|
|
content_filter: 'Content flagged by AI safety system. Please review input data.',
|
||
|
|
server_error: 'AI service temporarily unavailable. Please try again later.',
|
||
|
|
unknown: 'An unexpected error occurred. Please try again.',
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get a user-friendly message for an error type
|
||
|
|
*/
|
||
|
|
export function getUserFriendlyMessage(errorType: AIErrorType): string {
|
||
|
|
return USER_FRIENDLY_MESSAGES[errorType]
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Error Handling Helpers ──────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Wrap an async function with standardized AI error handling
|
||
|
|
*/
|
||
|
|
export async function withAIErrorHandling<T>(
|
||
|
|
fn: () => Promise<T>,
|
||
|
|
fallback: T
|
||
|
|
): Promise<{ result: T; error?: ClassifiedError }> {
|
||
|
|
try {
|
||
|
|
const result = await fn()
|
||
|
|
return { result }
|
||
|
|
} catch (error) {
|
||
|
|
const classified = classifyAIError(error)
|
||
|
|
console.error(`[AI Error] ${classified.type}:`, classified.originalMessage)
|
||
|
|
return { result: fallback, error: classified }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Log an AI error with context
|
||
|
|
*/
|
||
|
|
export function logAIError(
|
||
|
|
service: string,
|
||
|
|
operation: string,
|
||
|
|
error: ClassifiedError,
|
||
|
|
context?: Record<string, unknown>
|
||
|
|
): void {
|
||
|
|
console.error(
|
||
|
|
`[AI ${service}] ${operation} failed:`,
|
||
|
|
JSON.stringify({
|
||
|
|
type: error.type,
|
||
|
|
message: error.message,
|
||
|
|
originalMessage: error.originalMessage,
|
||
|
|
retryable: error.retryable,
|
||
|
|
...context,
|
||
|
|
})
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Retry Logic ─────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Determine if an operation should be retried based on error type
|
||
|
|
*/
|
||
|
|
export function shouldRetry(error: ClassifiedError, attempt: number, maxAttempts: number = 3): boolean {
|
||
|
|
if (!error.retryable) return false
|
||
|
|
if (attempt >= maxAttempts) return false
|
||
|
|
|
||
|
|
// Rate limits need longer delays
|
||
|
|
if (error.type === 'rate_limit') {
|
||
|
|
return attempt < 2 // Only retry once for rate limits
|
||
|
|
}
|
||
|
|
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Calculate delay before retry (exponential backoff)
|
||
|
|
*/
|
||
|
|
export function getRetryDelay(error: ClassifiedError, attempt: number): number {
|
||
|
|
const baseDelay = error.type === 'rate_limit' ? 30000 : 1000 // 30s for rate limit, 1s otherwise
|
||
|
|
return baseDelay * Math.pow(2, attempt)
|
||
|
|
}
|