402 lines
12 KiB
Lua
402 lines
12 KiB
Lua
|
-- Centralized error handling and recovery system
|
||
|
local M = {}
|
||
|
|
||
|
local logging = require('notex.utils.logging')
|
||
|
|
||
|
-- Error types with specific handling strategies
|
||
|
local ERROR_TYPES = {
|
||
|
DATABASE_CONNECTION = {
|
||
|
category = "database",
|
||
|
recoverable = true,
|
||
|
retry_strategy = "exponential_backoff",
|
||
|
max_retries = 3,
|
||
|
user_message = "Database connection error. Retrying..."
|
||
|
},
|
||
|
DATABASE_QUERY = {
|
||
|
category = "database",
|
||
|
recoverable = false,
|
||
|
retry_strategy = "none",
|
||
|
max_retries = 0,
|
||
|
user_message = "Query execution failed. Please check your query syntax."
|
||
|
},
|
||
|
FILE_NOT_FOUND = {
|
||
|
category = "filesystem",
|
||
|
recoverable = true,
|
||
|
retry_strategy = "immediate",
|
||
|
max_retries = 1,
|
||
|
user_message = "File not found. It may have been moved or deleted."
|
||
|
},
|
||
|
FILE_PARSE_ERROR = {
|
||
|
category = "parsing",
|
||
|
recoverable = false,
|
||
|
retry_strategy = "none",
|
||
|
max_retries = 0,
|
||
|
user_message = "Failed to parse file. Please check the file format."
|
||
|
},
|
||
|
QUERY_SYNTAX_ERROR = {
|
||
|
category = "query",
|
||
|
recoverable = false,
|
||
|
retry_strategy = "none",
|
||
|
max_retries = 0,
|
||
|
user_message = "Query syntax error. Please check your query syntax."
|
||
|
},
|
||
|
VALIDATION_ERROR = {
|
||
|
category = "validation",
|
||
|
recoverable = false,
|
||
|
retry_strategy = "none",
|
||
|
max_retries = 0,
|
||
|
user_message = "Validation error. Please check your input."
|
||
|
},
|
||
|
UI_ERROR = {
|
||
|
category = "ui",
|
||
|
recoverable = true,
|
||
|
retry_strategy = "immediate",
|
||
|
max_retries = 1,
|
||
|
user_message = "UI error. Attempting to recover..."
|
||
|
},
|
||
|
PERMISSION_ERROR = {
|
||
|
category = "filesystem",
|
||
|
recoverable = false,
|
||
|
retry_strategy = "none",
|
||
|
max_retries = 0,
|
||
|
user_message = "Permission denied. Please check file permissions."
|
||
|
},
|
||
|
NETWORK_ERROR = {
|
||
|
category = "network",
|
||
|
recoverable = true,
|
||
|
retry_strategy = "exponential_backoff",
|
||
|
max_retries = 3,
|
||
|
user_message = "Network error. Retrying..."
|
||
|
},
|
||
|
PERFORMANCE_TIMEOUT = {
|
||
|
category = "performance",
|
||
|
recoverable = true,
|
||
|
retry_strategy = "immediate",
|
||
|
max_retries = 1,
|
||
|
user_message = "Operation timed out. Retrying with simpler approach..."
|
||
|
}
|
||
|
}
|
||
|
|
||
|
-- Error state tracking
|
||
|
local error_state = {
|
||
|
recent_errors = {},
|
||
|
error_counts = {},
|
||
|
last_recovery_attempt = {},
|
||
|
recovery_in_progress = {}
|
||
|
}
|
||
|
|
||
|
-- Create standardized error object
|
||
|
function M.create_error(error_type, message, context, original_error)
|
||
|
local error_def = ERROR_TYPES[error_type] or ERROR_TYPES.UI_ERROR
|
||
|
local error_obj = {
|
||
|
type = error_type,
|
||
|
message = message,
|
||
|
context = context or {},
|
||
|
original_error = original_error,
|
||
|
timestamp = os.time(),
|
||
|
recoverable = error_def.recoverable,
|
||
|
category = error_def.category,
|
||
|
user_message = error_def.user_message,
|
||
|
retry_strategy = error_def.retry_strategy,
|
||
|
max_retries = error_def.max_retries,
|
||
|
error_id = M.generate_error_id()
|
||
|
}
|
||
|
|
||
|
-- Track error
|
||
|
M.track_error(error_obj)
|
||
|
|
||
|
return error_obj
|
||
|
end
|
||
|
|
||
|
-- Generate unique error ID
|
||
|
function M.generate_error_id()
|
||
|
return string.format("ERR_%d_%s", os.time(), math.random(1000, 9999))
|
||
|
end
|
||
|
|
||
|
-- Track error occurrence
|
||
|
function M.track_error(error_obj)
|
||
|
-- Add to recent errors
|
||
|
table.insert(error_state.recent_errors, error_obj)
|
||
|
|
||
|
-- Keep only last 50 errors
|
||
|
if #error_state.recent_errors > 50 then
|
||
|
table.remove(error_state.recent_errors, 1)
|
||
|
end
|
||
|
|
||
|
-- Update error counts
|
||
|
local key = error_obj.type
|
||
|
error_state.error_counts[key] = (error_state.error_counts[key] or 0) + 1
|
||
|
|
||
|
-- Log the error
|
||
|
logging.handle_error(error_obj.message, error_obj.category, error_obj)
|
||
|
end
|
||
|
|
||
|
-- Check if error should be retried
|
||
|
function M.should_retry(error_obj, current_attempt)
|
||
|
if not error_obj.recoverable then
|
||
|
return false, "Error is not recoverable"
|
||
|
end
|
||
|
|
||
|
if current_attempt >= error_obj.max_retries then
|
||
|
return false, "Maximum retries exceeded"
|
||
|
end
|
||
|
|
||
|
-- Check if we recently attempted recovery for this error type
|
||
|
local last_attempt = error_state.last_recovery_attempt[error_obj.type]
|
||
|
if last_attempt and (os.time() - last_attempt) < 5 then
|
||
|
return false, "Recovery attempt too recent"
|
||
|
end
|
||
|
|
||
|
return true, "Retry allowed"
|
||
|
end
|
||
|
|
||
|
-- Execute operation with error handling and recovery
|
||
|
function M.safe_execute(operation, error_type, context, func, ...)
|
||
|
local current_attempt = 0
|
||
|
local max_attempts = (ERROR_TYPES[error_type] and ERROR_TYPES[error_type].max_retries or 0) + 1
|
||
|
|
||
|
while current_attempt < max_attempts do
|
||
|
local success, result = pcall(func, ...)
|
||
|
|
||
|
if success then
|
||
|
-- Reset recovery state on success
|
||
|
error_state.last_recovery_attempt[error_type] = nil
|
||
|
error_state.recovery_in_progress[error_type] = nil
|
||
|
return true, result
|
||
|
else
|
||
|
current_attempt = current_attempt + 1
|
||
|
local error_obj = M.create_error(error_type, result, context)
|
||
|
|
||
|
local should_retry, retry_reason = M.should_retry(error_obj, current_attempt)
|
||
|
|
||
|
if should_retry and current_attempt < max_attempts then
|
||
|
error_state.last_recovery_attempt[error_type] = os.time()
|
||
|
error_state.recovery_in_progress[error_type] = true
|
||
|
|
||
|
-- Show user message
|
||
|
if error_obj.user_message then
|
||
|
vim.notify(error_obj.user_message, vim.log.levels.WARN)
|
||
|
end
|
||
|
|
||
|
-- Apply retry strategy
|
||
|
M.apply_retry_strategy(error_obj.retry_strategy, current_attempt)
|
||
|
|
||
|
logging.info("Retrying operation", {
|
||
|
operation = operation,
|
||
|
attempt = current_attempt,
|
||
|
error_type = error_type,
|
||
|
reason = retry_reason
|
||
|
})
|
||
|
else
|
||
|
-- Final failure
|
||
|
error_state.recovery_in_progress[error_type] = nil
|
||
|
|
||
|
-- Show final error message
|
||
|
M.show_final_error(error_obj, current_attempt)
|
||
|
|
||
|
return false, error_obj
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
return false, "Operation failed after all retry attempts"
|
||
|
end
|
||
|
|
||
|
-- Apply retry strategy
|
||
|
function M.apply_retry_strategy(strategy, attempt)
|
||
|
if strategy == "immediate" then
|
||
|
-- No delay
|
||
|
elseif strategy == "exponential_backoff" then
|
||
|
local delay = math.min(2 ^ attempt, 10) -- Cap at 10 seconds
|
||
|
vim.defer_fn(function() end, delay * 1000)
|
||
|
elseif strategy == "linear_backoff" then
|
||
|
local delay = attempt * 1000 -- 1 second per attempt
|
||
|
vim.defer_fn(function() end, delay)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
-- Show final error to user
|
||
|
function M.show_final_error(error_obj, attempt_count)
|
||
|
local message = string.format("%s (%d attempts made)", error_obj.user_message or error_obj.message, attempt_count)
|
||
|
|
||
|
if error_obj.category == "validation" or error_obj.category == "query" then
|
||
|
vim.notify(message, vim.log.levels.ERROR)
|
||
|
elseif error_obj.category == "filesystem" or error_obj.category == "database" then
|
||
|
vim.notify(message, vim.log.levels.ERROR)
|
||
|
else
|
||
|
vim.notify(message, vim.log.levels.WARN)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
-- Wrap function for safe execution
|
||
|
function M.wrap(operation_name, error_type, func)
|
||
|
return function(...)
|
||
|
return M.safe_execute(operation_name, error_type, {operation = operation_name}, func, ...)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
-- Handle specific error types with custom recovery
|
||
|
local error_handlers = {}
|
||
|
|
||
|
function M.register_error_handler(error_type, handler)
|
||
|
error_handlers[error_type] = handler
|
||
|
end
|
||
|
|
||
|
function M.handle_specific_error(error_obj)
|
||
|
local handler = error_handlers[error_obj.type]
|
||
|
if handler then
|
||
|
local success, result = pcall(handler, error_obj)
|
||
|
if success then
|
||
|
return result
|
||
|
else
|
||
|
logging.error("Error handler failed", {
|
||
|
error_type = error_obj.type,
|
||
|
handler_error = result
|
||
|
})
|
||
|
end
|
||
|
end
|
||
|
|
||
|
return nil
|
||
|
end
|
||
|
|
||
|
-- Register default error handlers
|
||
|
M.register_error_handler("DATABASE_CONNECTION", function(error_obj)
|
||
|
-- Try to reinitialize database connection
|
||
|
local database = require('notex.database.init')
|
||
|
local ok, err = database.reconnect()
|
||
|
if ok then
|
||
|
vim.notify("Database connection restored", vim.log.levels.INFO)
|
||
|
return true
|
||
|
end
|
||
|
return false
|
||
|
end)
|
||
|
|
||
|
M.register_error_handler("FILE_NOT_FOUND", function(error_obj)
|
||
|
-- Remove from index if file no longer exists
|
||
|
if error_obj.context and error_obj.context.file_path then
|
||
|
local indexer = require('notex.index')
|
||
|
local ok, err = indexer.remove_document_by_path(error_obj.context.file_path)
|
||
|
if ok then
|
||
|
vim.notify("Removed missing file from index", vim.log.levels.INFO)
|
||
|
return true
|
||
|
end
|
||
|
end
|
||
|
return false
|
||
|
end)
|
||
|
|
||
|
M.register_error_handler("UI_ERROR", function(error_obj)
|
||
|
-- Try to cleanup UI state
|
||
|
local ui = require('notex.ui')
|
||
|
ui.cleanup_all()
|
||
|
vim.notify("UI state reset", vim.log.levels.INFO)
|
||
|
return true
|
||
|
end)
|
||
|
|
||
|
-- Get error statistics
|
||
|
function M.get_error_statistics()
|
||
|
local stats = {
|
||
|
total_errors = 0,
|
||
|
by_type = vim.deepcopy(error_state.error_counts),
|
||
|
recent_errors = vim.list_slice(error_state.recent_errors, -10), -- Last 10 errors
|
||
|
recovery_in_progress = vim.deepcopy(error_state.recovery_in_progress)
|
||
|
}
|
||
|
|
||
|
-- Calculate total
|
||
|
for _, count in pairs(error_state.error_counts) do
|
||
|
stats.total_errors = stats.total_errors + count
|
||
|
end
|
||
|
|
||
|
-- Get error rate in last hour
|
||
|
local one_hour_ago = os.time() - 3600
|
||
|
local recent_count = 0
|
||
|
for _, error in ipairs(error_state.recent_errors) do
|
||
|
if error.timestamp > one_hour_ago then
|
||
|
recent_count = recent_count + 1
|
||
|
end
|
||
|
end
|
||
|
stats.errors_per_hour = recent_count
|
||
|
|
||
|
return stats
|
||
|
end
|
||
|
|
||
|
-- Clear error history
|
||
|
function M.clear_error_history()
|
||
|
error_state.recent_errors = {}
|
||
|
error_state.error_counts = {}
|
||
|
error_state.last_recovery_attempt = {}
|
||
|
error_state.recovery_in_progress = {}
|
||
|
|
||
|
logging.info("Error history cleared")
|
||
|
end
|
||
|
|
||
|
-- Check system health based on errors
|
||
|
function M.check_system_health()
|
||
|
local stats = M.get_error_statistics()
|
||
|
local health = {
|
||
|
status = "healthy",
|
||
|
issues = {},
|
||
|
recommendations = {}
|
||
|
}
|
||
|
|
||
|
-- Check error rate
|
||
|
if stats.errors_per_hour > 10 then
|
||
|
health.status = "degraded"
|
||
|
table.insert(health.issues, "High error rate: " .. stats.errors_per_hour .. " errors/hour")
|
||
|
table.insert(health.recommendations, "Check system logs for recurring issues")
|
||
|
end
|
||
|
|
||
|
-- Check for stuck recovery operations
|
||
|
local stuck_recoveries = 0
|
||
|
for error_type, in_progress in pairs(error_state.recovery_in_progress) do
|
||
|
if in_progress then
|
||
|
stuck_recoveries = stuck_recoveries + 1
|
||
|
end
|
||
|
end
|
||
|
|
||
|
if stuck_recoveries > 0 then
|
||
|
health.status = "degraded"
|
||
|
table.insert(health.issues, stuck_recoveries .. " recovery operations in progress")
|
||
|
table.insert(health.recommendations, "Consider restarting the plugin")
|
||
|
end
|
||
|
|
||
|
-- Check for specific error patterns
|
||
|
local db_errors = error_state.error_counts["DATABASE_CONNECTION"] or 0
|
||
|
local file_errors = error_state.error_counts["FILE_NOT_FOUND"] or 0
|
||
|
|
||
|
if db_errors > 5 then
|
||
|
health.status = "unhealthy"
|
||
|
table.insert(health.issues, "Frequent database connection errors")
|
||
|
table.insert(health.recommendations, "Check database file permissions and disk space")
|
||
|
end
|
||
|
|
||
|
if file_errors > 10 then
|
||
|
table.insert(health.issues, "Many file not found errors")
|
||
|
table.insert(health.recommendations, "Consider reindexing the workspace")
|
||
|
end
|
||
|
|
||
|
return health
|
||
|
end
|
||
|
|
||
|
-- Create user-friendly error messages
|
||
|
function M.format_error_for_user(error_obj)
|
||
|
local message = error_obj.user_message or error_obj.message
|
||
|
|
||
|
-- Add contextual information
|
||
|
if error_obj.context.operation then
|
||
|
message = message .. " (during: " .. error_obj.context.operation .. ")"
|
||
|
end
|
||
|
|
||
|
if error_obj.context.file_path then
|
||
|
message = message .. " (file: " .. vim.fn.fnamemodify(error_obj.context.file_path, ":t") .. ")"
|
||
|
end
|
||
|
|
||
|
-- Add error ID for support
|
||
|
message = message .. " [ID: " .. error_obj.error_id .. "]"
|
||
|
|
||
|
return message
|
||
|
end
|
||
|
|
||
|
-- Export error types for use in other modules
|
||
|
M.ERROR_TYPES = ERROR_TYPES
|
||
|
|
||
|
return M
|