Coverage for src/local_deep_research/error_handling/error_reporter.py: 90%
81 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 23:15 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 23:15 +0000
1"""
2ErrorReporter - Main error categorization and handling logic
3"""
5import re
6from enum import Enum
7from typing import Any, Dict, Optional
9from loguru import logger
12class ErrorCategory(Enum):
13 """Categories of errors that can occur during research"""
15 CONNECTION_ERROR = "connection_error"
16 MODEL_ERROR = "model_error"
17 SEARCH_ERROR = "search_error"
18 SYNTHESIS_ERROR = "synthesis_error"
19 FILE_ERROR = "file_error"
20 RATE_LIMIT_ERROR = "rate_limit_error"
21 UNKNOWN_ERROR = "unknown_error"
24class ErrorReporter:
25 """
26 Analyzes and categorizes errors to provide better user feedback
27 """
29 def __init__(self):
30 self.error_patterns = {
31 ErrorCategory.CONNECTION_ERROR: [
32 r"POST predict.*EOF",
33 r"Connection refused",
34 r"timeout",
35 r"Connection.*failed",
36 r"HTTP error \d+",
37 r"network.*error",
38 r"\[Errno 111\]",
39 r"host\.docker\.internal",
40 r"host.*localhost.*Docker",
41 r"127\.0\.0\.1.*Docker",
42 r"localhost.*1234.*Docker",
43 r"LM.*Studio.*Docker.*Mac",
44 # OpenAI-compatible endpoint tokens (#3878)
45 r"Error type: openai_connection_refused",
46 r"Error type: openai_timeout",
47 ],
48 ErrorCategory.MODEL_ERROR: [
49 r"Model.*not found",
50 r"Invalid.*model",
51 r"Ollama.*not available",
52 r"API key.*invalid",
53 r"Authentication.*error",
54 r"max_workers must be greater than 0",
55 r"TypeError.*Context.*Size",
56 r"'<' not supported between",
57 r"No auth credentials found",
58 r"401.*API key",
59 # OpenAI-compatible endpoint tokens (#3878)
60 r"Error type: openai_auth",
61 r"Error type: openai_permission_denied",
62 r"Error type: openai_model_not_found",
63 r"Error type: openai_bad_request",
64 r"Error type: openai_unknown",
65 ],
66 ErrorCategory.RATE_LIMIT_ERROR: [
67 r"429.*resource.*exhausted",
68 r"429.*too many requests",
69 r"rate limit",
70 r"rate_limit",
71 r"ratelimit",
72 r"quota.*exceeded",
73 r"resource.*exhausted.*quota",
74 r"threshold.*requests",
75 r"LLM rate limit",
76 r"API rate limit",
77 r"maximum.*requests.*minute",
78 r"maximum.*requests.*hour",
79 # OpenAI-compatible endpoint token (#3878 follow-up)
80 r"Error type: openai_rate_limit",
81 ],
82 ErrorCategory.SEARCH_ERROR: [
83 r"Search.*failed",
84 r"No search results",
85 r"Search engine.*error",
86 r"The search is longer than 256 characters",
87 r"Failed to create search engine",
88 r"could not be found",
89 r"GitHub API error",
90 r"database.*locked",
91 ],
92 ErrorCategory.SYNTHESIS_ERROR: [
93 r"Error.*synthesis",
94 r"Failed.*generate",
95 r"Synthesis.*timeout",
96 r"detailed.*report.*stuck",
97 r"report.*taking.*long",
98 r"progress.*100.*stuck",
99 ],
100 ErrorCategory.FILE_ERROR: [
101 r"Permission denied",
102 r"File.*not found",
103 r"Cannot write.*file",
104 r"Disk.*full",
105 r"No module named.*local_deep_research",
106 r"HTTP error 404.*research results",
107 r"Attempt to write readonly database",
108 ],
109 }
111 def categorize_error(self, error_message: str) -> ErrorCategory:
112 """
113 Categorize an error based on its message
115 Args:
116 error_message: The error message to categorize
118 Returns:
119 ErrorCategory: The categorized error type
120 """
121 error_message = str(error_message).lower()
123 for category, patterns in self.error_patterns.items():
124 for pattern in patterns:
125 if re.search(pattern.lower(), error_message):
126 logger.debug(
127 f"Categorized error as {category.value}: {pattern}"
128 )
129 return category
131 return ErrorCategory.UNKNOWN_ERROR
133 def get_user_friendly_title(self, category: ErrorCategory) -> str:
134 """
135 Get a user-friendly title for an error category
137 Args:
138 category: The error category
140 Returns:
141 str: User-friendly title
142 """
143 titles = {
144 ErrorCategory.CONNECTION_ERROR: "Connection Issue",
145 ErrorCategory.MODEL_ERROR: "LLM Service Error",
146 ErrorCategory.SEARCH_ERROR: "Search Service Error",
147 ErrorCategory.SYNTHESIS_ERROR: "Report Generation Error",
148 ErrorCategory.FILE_ERROR: "File System Error",
149 ErrorCategory.RATE_LIMIT_ERROR: "API Rate Limit Exceeded",
150 ErrorCategory.UNKNOWN_ERROR: "Unexpected Error",
151 }
152 return titles.get(category, "Error")
154 def get_suggested_actions(self, category: ErrorCategory) -> list:
155 """
156 Get suggested actions for resolving an error
158 Args:
159 category: The error category
161 Returns:
162 list: List of suggested actions
163 """
164 suggestions = {
165 ErrorCategory.CONNECTION_ERROR: [
166 "Check if the LLM service (Ollama/LM Studio) is running",
167 "Verify network connectivity",
168 "Try switching to a different model provider",
169 "Check the service logs for more details",
170 ],
171 ErrorCategory.MODEL_ERROR: [
172 "Verify the model name is correct",
173 "Check if the model is downloaded and available",
174 "Validate API keys if using external services",
175 "Try switching to a different model",
176 ],
177 ErrorCategory.SEARCH_ERROR: [
178 "Check internet connectivity",
179 "Try reducing the number of search results",
180 "Wait a moment and try again",
181 "Check if search service is configured correctly",
182 "For local documents: ensure the path is absolute and folder exists",
183 "Try a different search engine if one is failing",
184 ],
185 ErrorCategory.SYNTHESIS_ERROR: [
186 "The research data was collected successfully",
187 "Try switching to a different model for report generation",
188 "Check the partial results below",
189 "Review the detailed logs for more information",
190 ],
191 ErrorCategory.FILE_ERROR: [
192 "Check disk space availability",
193 "Verify write permissions",
194 "Try changing the output directory",
195 "Restart the application",
196 ],
197 ErrorCategory.RATE_LIMIT_ERROR: [
198 "The API has reached its rate limit",
199 "Enable LLM Rate Limiting in Settings → Rate Limiting → Enable LLM Rate Limiting",
200 "Once enabled, the system will automatically learn and adapt to API limits",
201 "Consider upgrading to a paid API plan for higher limits",
202 "Try using a different model temporarily",
203 ],
204 ErrorCategory.UNKNOWN_ERROR: [
205 "Check the detailed logs below for more information",
206 "Try running the research again",
207 "Report this issue if it persists",
208 "Contact support with the error details",
209 ],
210 }
211 return suggestions.get(category, ["Check the logs for more details"])
213 def analyze_error(
214 self, error_message: str, context: Optional[Dict[str, Any]] = None
215 ) -> Dict[str, Any]:
216 """
217 Perform comprehensive error analysis
219 Args:
220 error_message: The error message to analyze
221 context: Optional context information
223 Returns:
224 dict: Comprehensive error analysis
225 """
226 category = self.categorize_error(error_message)
228 analysis = {
229 "category": category,
230 "title": self.get_user_friendly_title(category),
231 "original_error": error_message,
232 "suggestions": self.get_suggested_actions(category),
233 "severity": self._determine_severity(category),
234 "recoverable": self._is_recoverable(category),
235 }
237 # Add context-specific information
238 if context:
239 analysis["context"] = context
240 analysis["has_partial_results"] = bool(
241 context.get("findings")
242 or context.get("current_knowledge")
243 or context.get("search_results")
244 )
246 # Send notifications for specific error types
247 self._send_error_notifications(category, error_message, context)
249 return analysis
251 def _send_error_notifications(
252 self,
253 category: ErrorCategory,
254 error_message: str,
255 context: Optional[Dict[str, Any]] = None,
256 ) -> None:
257 """
258 Send notifications for specific error categories.
260 Args:
261 category: Error category
262 error_message: Error message
263 context: Optional context information
264 """
265 try:
266 # Only send notifications for AUTH and QUOTA errors
267 if category not in [
268 ErrorCategory.MODEL_ERROR,
269 ErrorCategory.RATE_LIMIT_ERROR,
270 ]:
271 return
273 # Try to get username from context
274 username = None
275 if context:
276 username = context.get("username")
278 # Don't send notifications if we can't determine user
279 if not username:
280 logger.debug(
281 "No username in context, skipping error notification"
282 )
283 return
285 from ..notifications.manager import NotificationManager
286 from ..notifications import EventType
287 from ..database.session_context import get_user_db_session
289 # Get settings snapshot for notification
290 with get_user_db_session(username) as session:
291 from ..settings import SettingsManager
293 settings_manager = SettingsManager(session)
294 settings_snapshot = settings_manager.get_settings_snapshot()
296 notification_manager = NotificationManager(
297 settings_snapshot=settings_snapshot, user_id=username
298 )
300 # Determine event type and build context
301 if category == ErrorCategory.MODEL_ERROR: 301 ↛ 321line 301 didn't jump to line 321 because the condition on line 301 was always true
302 # Check if it's an auth error specifically
303 error_str = error_message.lower()
304 if any( 304 ↛ 319line 304 didn't jump to line 319 because the condition on line 304 was always true
305 pattern in error_str
306 for pattern in [
307 "api key",
308 "authentication",
309 "401",
310 "unauthorized",
311 ]
312 ):
313 event_type = EventType.AUTH_ISSUE
314 notification_context = {
315 "service": self._extract_service_name(error_message),
316 }
317 else:
318 # Not an auth error, don't notify
319 return
321 elif category == ErrorCategory.RATE_LIMIT_ERROR:
322 event_type = EventType.API_QUOTA_WARNING
323 notification_context = {
324 "service": self._extract_service_name(error_message),
325 "current": "Unknown",
326 "limit": "Unknown",
327 "reset_time": "Unknown",
328 }
330 else:
331 return
333 # Send notification
334 notification_manager.send_notification(
335 event_type=event_type,
336 context=notification_context,
337 )
339 except Exception as e:
340 logger.debug(f"Failed to send error notification: {e}")
342 def _extract_service_name(self, error_message: str) -> str:
343 """
344 Extract service name from error message.
346 Args:
347 error_message: Error message
349 Returns:
350 Service name or "API Service"
351 """
352 error_lower = error_message.lower()
354 # Check for common service names
355 services = [
356 "openai",
357 "anthropic",
358 "google",
359 "ollama",
360 "searxng",
361 "tavily",
362 "brave",
363 ]
365 for service in services:
366 if service in error_lower:
367 return service.title()
369 return "API Service"
371 def _determine_severity(self, category: ErrorCategory) -> str:
372 """Determine error severity level"""
373 severity_map = {
374 ErrorCategory.CONNECTION_ERROR: "high",
375 ErrorCategory.MODEL_ERROR: "high",
376 ErrorCategory.SEARCH_ERROR: "medium",
377 ErrorCategory.SYNTHESIS_ERROR: "low", # Can often show partial results
378 ErrorCategory.FILE_ERROR: "medium",
379 ErrorCategory.RATE_LIMIT_ERROR: "medium", # Can be resolved with settings
380 ErrorCategory.UNKNOWN_ERROR: "high",
381 }
382 return severity_map.get(category, "medium")
384 def _is_recoverable(self, category: ErrorCategory) -> bool:
385 """Determine if error is recoverable with user action"""
386 recoverable = {
387 ErrorCategory.CONNECTION_ERROR: True,
388 ErrorCategory.MODEL_ERROR: True,
389 ErrorCategory.SEARCH_ERROR: True,
390 ErrorCategory.SYNTHESIS_ERROR: True,
391 ErrorCategory.FILE_ERROR: True,
392 ErrorCategory.RATE_LIMIT_ERROR: True, # Can enable rate limiting
393 ErrorCategory.UNKNOWN_ERROR: False,
394 }
395 return recoverable.get(category, False)