Coverage for src/local_deep_research/error_handling/error_reporter.py: 91%

90 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2ErrorReporter - Main error categorization and handling logic 

3""" 

4 

5import re 

6from enum import Enum 

7from typing import Any, Dict, Optional 

8 

9from loguru import logger 

10 

11from .error_messages import MAX_ERROR_CLASSIFICATION_CHARS 

12 

13 

14class ErrorCategory(Enum): 

15 """Categories of errors that can occur during research""" 

16 

17 CONNECTION_ERROR = "connection_error" 

18 MODEL_ERROR = "model_error" 

19 SEARCH_ERROR = "search_error" 

20 SYNTHESIS_ERROR = "synthesis_error" 

21 FILE_ERROR = "file_error" 

22 RATE_LIMIT_ERROR = "rate_limit_error" 

23 UNKNOWN_ERROR = "unknown_error" 

24 

25 

26class ErrorReporter: 

27 """ 

28 Analyzes and categorizes errors to provide better user feedback 

29 """ 

30 

31 def __init__(self): 

32 self.error_patterns = { 

33 ErrorCategory.CONNECTION_ERROR: [ 

34 r"POST predict.*EOF", 

35 r"Connection refused", 

36 r"timeout", 

37 r"Connection.*failed", 

38 r"HTTP error \d+", 

39 r"network.*error", 

40 r"\[Errno 111\]", 

41 r"host\.docker\.internal", 

42 r"host.*localhost.*Docker", 

43 r"127\.0\.0\.1.*Docker", 

44 r"localhost.*1234.*Docker", 

45 r"LM.*Studio.*Docker.*Mac", 

46 # OpenAI-compatible endpoint tokens (#3878) 

47 r"Error type: openai_connection_refused", 

48 r"Error type: openai_timeout", 

49 ], 

50 ErrorCategory.MODEL_ERROR: [ 

51 r"Model.*not found", 

52 r"Invalid.*model", 

53 r"Ollama.*not available", 

54 r"API key.*invalid", 

55 r"Authentication.*error", 

56 r"max_workers must be greater than 0", 

57 r"TypeError.*Context.*Size", 

58 r"'<' not supported between instances of .* and 'NoneType'", 

59 r"No auth credentials found", 

60 r"401.*API key", 

61 # OpenAI-compatible endpoint tokens (#3878) 

62 r"Error type: openai_auth", 

63 r"Error type: openai_permission_denied", 

64 r"Error type: openai_model_not_found", 

65 r"Error type: openai_bad_request", 

66 r"Error type: openai_unknown", 

67 ], 

68 ErrorCategory.RATE_LIMIT_ERROR: [ 

69 r"429.*resource.*exhausted", 

70 r"429.*too many requests", 

71 r"rate limit", 

72 r"rate_limit", 

73 r"ratelimit", 

74 r"quota.*exceeded", 

75 r"resource.*exhausted.*quota", 

76 r"threshold.*requests", 

77 r"LLM rate limit", 

78 r"API rate limit", 

79 r"maximum.*requests.*minute", 

80 r"maximum.*requests.*hour", 

81 # OpenAI-compatible endpoint token (#3878 follow-up) 

82 r"Error type: openai_rate_limit", 

83 ], 

84 ErrorCategory.SEARCH_ERROR: [ 

85 r"Search.*failed", 

86 r"No search results", 

87 r"Search engine.*error", 

88 r"The search is longer than 256 characters", 

89 r"Failed to create search engine", 

90 r"search engine.*could not be found", 

91 r"GitHub API error", 

92 ], 

93 ErrorCategory.SYNTHESIS_ERROR: [ 

94 r"Error.*synthesis", 

95 r"Failed.*generate", 

96 r"Synthesis.*timeout", 

97 r"detailed.*report.*stuck", 

98 r"report.*taking.*long", 

99 r"progress.*100.*stuck", 

100 ], 

101 ErrorCategory.FILE_ERROR: [ 

102 r"Permission denied", 

103 r"File.*not found", 

104 r"Cannot write.*file", 

105 r"Disk.*full", 

106 r"No module named.*local_deep_research", 

107 r"HTTP error 404.*research results", 

108 r"Attempt to write readonly database", 

109 r"database is locked", 

110 r"database table is locked", 

111 ], 

112 } 

113 

114 def categorize_error(self, error_message: str) -> ErrorCategory: 

115 """ 

116 Categorize an error based on its message 

117 

118 Args: 

119 error_message: The error message to categorize 

120 

121 Returns: 

122 ErrorCategory: The categorized error type 

123 """ 

124 # Keep the regex work bounded even when a provider includes a large 

125 # response body in its exception. Slice after lowercasing too, since 

126 # Unicode case conversion can expand a character into several. 

127 error_message = str(error_message)[:MAX_ERROR_CLASSIFICATION_CHARS] 

128 error_message = error_message.lower()[:MAX_ERROR_CLASSIFICATION_CHARS] 

129 

130 for category, patterns in self.error_patterns.items(): 

131 for pattern in patterns: 

132 if re.search(pattern.lower(), error_message): 

133 logger.debug( 

134 f"Categorized error as {category.value}: {pattern}" 

135 ) 

136 return category 

137 

138 return ErrorCategory.UNKNOWN_ERROR 

139 

140 def get_user_friendly_title(self, category: ErrorCategory) -> str: 

141 """ 

142 Get a user-friendly title for an error category 

143 

144 Args: 

145 category: The error category 

146 

147 Returns: 

148 str: User-friendly title 

149 """ 

150 titles = { 

151 ErrorCategory.CONNECTION_ERROR: "Connection Issue", 

152 ErrorCategory.MODEL_ERROR: "LLM Service Error", 

153 ErrorCategory.SEARCH_ERROR: "Search Service Error", 

154 ErrorCategory.SYNTHESIS_ERROR: "Report Generation Error", 

155 ErrorCategory.FILE_ERROR: "File System Error", 

156 ErrorCategory.RATE_LIMIT_ERROR: "API Rate Limit Exceeded", 

157 ErrorCategory.UNKNOWN_ERROR: "Unexpected Error", 

158 } 

159 return titles.get(category, "Error") 

160 

161 def get_suggested_actions(self, category: ErrorCategory) -> list: 

162 """ 

163 Get suggested actions for resolving an error 

164 

165 Args: 

166 category: The error category 

167 

168 Returns: 

169 list: List of suggested actions 

170 """ 

171 suggestions = { 

172 ErrorCategory.CONNECTION_ERROR: [ 

173 "Check if the LLM service (Ollama/LM Studio) is running", 

174 "Verify network connectivity", 

175 "Try switching to a different model provider", 

176 "Check the service logs for more details", 

177 ], 

178 ErrorCategory.MODEL_ERROR: [ 

179 "Verify the model name is correct", 

180 "Check if the model is downloaded and available", 

181 "Validate API keys if using external services", 

182 "Try switching to a different model", 

183 ], 

184 ErrorCategory.SEARCH_ERROR: [ 

185 "Check internet connectivity", 

186 "Try reducing the number of search results", 

187 "Wait a moment and try again", 

188 "Check if search service is configured correctly", 

189 "For local documents: ensure the path is absolute and folder exists", 

190 "Try a different search engine if one is failing", 

191 ], 

192 ErrorCategory.SYNTHESIS_ERROR: [ 

193 "The research data was collected successfully", 

194 "Try switching to a different model for report generation", 

195 "Check the partial results below", 

196 "Review the detailed logs for more information", 

197 ], 

198 ErrorCategory.FILE_ERROR: [ 

199 "Check disk space availability", 

200 "Verify write permissions", 

201 "Try changing the output directory", 

202 "Restart the application", 

203 ], 

204 ErrorCategory.RATE_LIMIT_ERROR: [ 

205 "The API has reached its rate limit", 

206 "Enable LLM Rate Limiting in Settings → Rate Limiting → Enable LLM Rate Limiting", 

207 "Once enabled, the system will automatically learn and adapt to API limits", 

208 "Consider upgrading to a paid API plan for higher limits", 

209 "Try using a different model temporarily", 

210 ], 

211 ErrorCategory.UNKNOWN_ERROR: [ 

212 "Check the detailed logs below for more information", 

213 "Try running the research again", 

214 "Report this issue if it persists", 

215 "Contact support with the error details", 

216 ], 

217 } 

218 return suggestions.get(category, ["Check the logs for more details"]) 

219 

220 def analyze_error( 

221 self, error_message: str, context: Optional[Dict[str, Any]] = None 

222 ) -> Dict[str, Any]: 

223 """ 

224 Perform comprehensive error analysis 

225 

226 Args: 

227 error_message: The error message to analyze 

228 context: Optional context information 

229 

230 Returns: 

231 dict: Comprehensive error analysis 

232 """ 

233 category = self.categorize_error(error_message) 

234 

235 analysis = { 

236 "category": category, 

237 "title": self.get_user_friendly_title(category), 

238 "original_error": error_message, 

239 "suggestions": self.get_suggested_actions(category), 

240 "severity": self._determine_severity(category), 

241 "recoverable": self._is_recoverable(category), 

242 } 

243 

244 # Add context-specific information 

245 if context: 

246 analysis["context"] = context 

247 analysis["has_partial_results"] = bool( 

248 context.get("findings") 

249 or context.get("current_knowledge") 

250 or context.get("search_results") 

251 ) 

252 

253 # Send notifications for specific error types 

254 self._send_error_notifications(category, error_message, context) 

255 

256 return analysis 

257 

258 def _send_error_notifications( 

259 self, 

260 category: ErrorCategory, 

261 error_message: str, 

262 context: Optional[Dict[str, Any]] = None, 

263 ) -> None: 

264 """ 

265 Send notifications for specific error categories. 

266 

267 Args: 

268 category: Error category 

269 error_message: Error message 

270 context: Optional context information 

271 """ 

272 try: 

273 # Only send notifications for AUTH and QUOTA errors 

274 if category not in [ 

275 ErrorCategory.MODEL_ERROR, 

276 ErrorCategory.RATE_LIMIT_ERROR, 

277 ]: 

278 return 

279 

280 # Try to get username from context 

281 username = None 

282 if context: 

283 username = context.get("username") 

284 

285 # Don't send notifications if we can't determine user 

286 if not username: 

287 logger.debug( 

288 "No username in context, skipping error notification" 

289 ) 

290 return 

291 

292 from ..notifications.manager import NotificationManager 

293 from ..notifications import EventType 

294 from ..database.session_context import get_user_db_session 

295 

296 # Get settings snapshot for notification 

297 with get_user_db_session(username) as session: 

298 from ..settings import SettingsManager 

299 

300 settings_manager = SettingsManager(session) 

301 settings_snapshot = settings_manager.get_settings_snapshot() 

302 

303 notification_manager = NotificationManager( 

304 settings_snapshot=settings_snapshot, user_id=username 

305 ) 

306 

307 # Determine event type and build context 

308 if category == ErrorCategory.MODEL_ERROR: 308 ↛ 328line 308 didn't jump to line 328 because the condition on line 308 was always true

309 # Check if it's an auth error specifically 

310 error_str = error_message.lower() 

311 if any( 311 ↛ 326line 311 didn't jump to line 326 because the condition on line 311 was always true

312 pattern in error_str 

313 for pattern in [ 

314 "api key", 

315 "authentication", 

316 "401", 

317 "unauthorized", 

318 ] 

319 ): 

320 event_type = EventType.AUTH_ISSUE 

321 notification_context = { 

322 "service": self._extract_service_name(error_message), 

323 } 

324 else: 

325 # Not an auth error, don't notify 

326 return 

327 

328 elif category == ErrorCategory.RATE_LIMIT_ERROR: 

329 event_type = EventType.API_QUOTA_WARNING 

330 notification_context = { 

331 "service": self._extract_service_name(error_message), 

332 "current": "Unknown", 

333 "limit": "Unknown", 

334 "reset_time": "Unknown", 

335 } 

336 

337 else: 

338 return 

339 

340 # Send notification 

341 result = notification_manager.send_notification( 

342 event_type=event_type, 

343 context=notification_context, 

344 ) 

345 if not result: 

346 # DEBUG so it doesn't pollute the default log, but operators 

347 # enabling ``--log-level debug`` can see precisely why the 

348 # error-notification didn't fire (e.g. server_disabled vs. 

349 # unconfigured). Use ``reason.value`` so the enum renders 

350 # as the clean string ("unconfigured") rather than 

351 # "NotificationReason.UNCONFIGURED". Include ``detail`` 

352 # when present so the operator-facing explanation is not 

353 # silently dropped. See issue #4877. 

354 reason_value = getattr( 

355 getattr(result, "reason", None), "value", None 

356 ) 

357 detail = getattr(result, "detail", "") or "" 

358 if reason_value is None: 

359 # Legacy bool-returning mocks/callers carry no reason. 

360 reason_value = "dropped" 

361 message = ( 

362 f"{reason_value}: {detail}" if detail else reason_value 

363 ) 

364 logger.debug("Error notification not sent: {}", message) 

365 

366 except Exception as e: 

367 logger.debug(f"Failed to send error notification: {e}") 

368 

369 def _extract_service_name(self, error_message: str) -> str: 

370 """ 

371 Extract service name from error message. 

372 

373 Args: 

374 error_message: Error message 

375 

376 Returns: 

377 Service name or "API Service" 

378 """ 

379 error_lower = error_message.lower() 

380 

381 # Check for common service names 

382 services = [ 

383 "openai", 

384 "anthropic", 

385 "google", 

386 "ollama", 

387 "searxng", 

388 "tavily", 

389 "brave", 

390 ] 

391 

392 for service in services: 

393 if service in error_lower: 

394 return service.title() 

395 

396 return "API Service" 

397 

398 def _determine_severity(self, category: ErrorCategory) -> str: 

399 """Determine error severity level""" 

400 severity_map = { 

401 ErrorCategory.CONNECTION_ERROR: "high", 

402 ErrorCategory.MODEL_ERROR: "high", 

403 ErrorCategory.SEARCH_ERROR: "medium", 

404 ErrorCategory.SYNTHESIS_ERROR: "low", # Can often show partial results 

405 ErrorCategory.FILE_ERROR: "medium", 

406 ErrorCategory.RATE_LIMIT_ERROR: "medium", # Can be resolved with settings 

407 ErrorCategory.UNKNOWN_ERROR: "high", 

408 } 

409 return severity_map.get(category, "medium") 

410 

411 def _is_recoverable(self, category: ErrorCategory) -> bool: 

412 """Determine if error is recoverable with user action""" 

413 recoverable = { 

414 ErrorCategory.CONNECTION_ERROR: True, 

415 ErrorCategory.MODEL_ERROR: True, 

416 ErrorCategory.SEARCH_ERROR: True, 

417 ErrorCategory.SYNTHESIS_ERROR: True, 

418 ErrorCategory.FILE_ERROR: True, 

419 ErrorCategory.RATE_LIMIT_ERROR: True, # Can enable rate limiting 

420 ErrorCategory.UNKNOWN_ERROR: False, 

421 } 

422 return recoverable.get(category, False)