Coverage for src/local_deep_research/library/download_management/failure_classifier.py: 97%

96 statements  

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

1""" 

2Failure Classification System with Inheritance 

3 

4Provides base classes and specific failure types for download attempts. 

5Uses inheritance to organize different failure categories and their retry behavior. 

6""" 

7 

8from abc import ABC 

9from datetime import datetime, timedelta, UTC 

10from typing import Optional 

11 

12from loguru import logger 

13 

14from ...security import ( 

15 failure_reason_for_log, 

16 url_authority_without_userinfo, 

17) 

18 

19 

20class BaseFailure(ABC): 

21 """Base class for all failure types with common functionality""" 

22 

23 def __init__( 

24 self, 

25 error_type: str, 

26 message: str, 

27 retry_after: Optional[timedelta] = None, 

28 ): 

29 """ 

30 Initialize a failure classification. 

31 

32 Args: 

33 error_type: Machine-readable error identifier 

34 message: Human-readable error description 

35 retry_after: When this failure can be retried (None = permanent) 

36 """ 

37 self.error_type = error_type 

38 self.message = message 

39 self.retry_after = retry_after 

40 self.created_at = datetime.now(UTC) 

41 

42 logger.debug( 

43 f"Created {self.__class__.__name__}: {error_type} - {message}" 

44 ) 

45 

46 def is_permanent(self) -> bool: 

47 """Check if this is a permanent failure (never retry)""" 

48 return self.retry_after is None 

49 

50 

51class PermanentFailure(BaseFailure): 

52 """Resources that should never be retried""" 

53 

54 def __init__(self, error_type: str, message: str): 

55 super().__init__(error_type, message, retry_after=None) 

56 

57 

58class TemporaryFailure(BaseFailure): 

59 """Resources that can be retried after cooldown""" 

60 

61 def __init__(self, error_type: str, message: str, cooldown: timedelta): 

62 super().__init__(error_type, message, retry_after=cooldown) 

63 

64 

65class RateLimitFailure(TemporaryFailure): 

66 """Domain-specific rate limit handling with longer cooldowns""" 

67 

68 def __init__(self, domain: str, details: str = ""): 

69 # Domain-specific cooldown periods 

70 domain_cooldowns = { 

71 "arxiv.org": timedelta( 

72 hours=6 

73 ), # General arXiv rate limiting (reCAPTCHA handled separately) 

74 "pubmed.ncbi.nlm.nih.gov": timedelta(hours=2), # PubMed rate limits 

75 "biorxiv.org": timedelta(hours=6), # BioRxiv rate limits 

76 "semanticscholar.org": timedelta( 

77 hours=4 

78 ), # Semantic Scholar rate limits 

79 "researchgate.net": timedelta(hours=12), # ResearchGate rate limits 

80 "default": timedelta( 

81 hours=1 

82 ), # Default cooldown for unknown domains 

83 } 

84 

85 cooldown = domain_cooldowns.get(domain, domain_cooldowns["default"]) 

86 message = f"Rate limited by {domain}" 

87 if details: 

88 message += f" - {details}" 

89 

90 super().__init__("rate_limited", message, cooldown) 

91 self.domain = domain 

92 

93 

94# Caller-safe skip-reason tokens emitted by 

95# ``security.client_safe_errors.client_safe_download_message`` (PR #6564). 

96# ``RetryManager.record_attempt`` receives the token as the string 

97# ``details`` (with ``error_type`` collapsed to the literal ``"str"``), so 

98# the classifier must recognise the token vocabulary in *both* fields — 

99# otherwise ``network_timeout`` falls through to ``unknown_error`` (1h + 

100# "Unclassified error" warning) where the pre-token text classified as 

101# ``timeout`` (30-min). All entries stay Temporary (no retryable -> terminal 

102# flip); the token itself is preserved as ``error_type`` so the UI 

103# distinction survives. 

104_CLIENT_SAFE_TOKEN_FAILURES: dict[str, tuple[str, str, timedelta]] = { 

105 "network_timeout": ("timeout", "Request timed out", timedelta(minutes=30)), 

106 "network_unavailable": ( 

107 "network_error", 

108 "Network connectivity issue", 

109 timedelta(minutes=5), 

110 ), 

111 "network_reset": ( 

112 "network_error", 

113 "Network connectivity issue", 

114 timedelta(minutes=5), 

115 ), 

116 "http_error": ("http_error", "HTTP error", timedelta(hours=1)), 

117 "network_redirect_limit": ( 

118 "network_redirect_limit", 

119 "Too many redirects", 

120 timedelta(hours=1), 

121 ), 

122 "database_constraint": ( 

123 "database_constraint", 

124 "Database constraint violation", 

125 timedelta(hours=1), 

126 ), 

127 "database_unavailable": ( 

128 "database_unavailable", 

129 "Database temporarily unavailable", 

130 timedelta(hours=1), 

131 ), 

132 "database_error": ( 

133 "database_error", 

134 "Database error", 

135 timedelta(hours=1), 

136 ), 

137 "database_data_error": ( 

138 "database_data_error", 

139 "Database data error", 

140 timedelta(hours=1), 

141 ), 

142 "database_interface_error": ( 

143 "database_interface_error", 

144 "Database interface error", 

145 timedelta(hours=1), 

146 ), 

147 "database_invalid_request": ( 

148 "database_invalid_request", 

149 "Database invalid request", 

150 timedelta(hours=1), 

151 ), 

152 "pdf_parse_failed": ( 

153 "pdf_parse_failed", 

154 "PDF parsing failed", 

155 timedelta(hours=1), 

156 ), 

157} 

158 

159 

160class FailureClassifier: 

161 """Classifies download failures into appropriate types based on error patterns""" 

162 

163 def classify_failure( 

164 self, 

165 error_type: str, 

166 status_code: Optional[int] = None, 

167 url: str = "", 

168 details: str = "", 

169 ) -> BaseFailure: 

170 """ 

171 Classify a download failure based on error information. 

172 

173 Args: 

174 error_type: Error type identifier 

175 status_code: HTTP status code if available 

176 url: URL that failed 

177 details: Additional error details 

178 

179 Returns: 

180 Appropriate failure classification 

181 """ 

182 # HTTP Status Code classifications 

183 if status_code: 

184 if status_code == 202: 

185 return TemporaryFailure( 

186 "processing", 

187 "Request accepted but not yet ready (202)", 

188 timedelta(minutes=30), 

189 ) 

190 if status_code == 404: 

191 return PermanentFailure("not_found", "Resource not found (404)") 

192 if status_code == 403: 

193 return PermanentFailure("forbidden", "Access forbidden (403)") 

194 if status_code == 410: 

195 return PermanentFailure( 

196 "gone", "Resource permanently removed (410)" 

197 ) 

198 if status_code == 429: 

199 # netloc minus userinfo: the domain lands in the failure 

200 # message, which is logged and persisted. 

201 domain = ( 

202 url_authority_without_userinfo(url) if url else "unknown" 

203 ) 

204 return RateLimitFailure(domain, details) 

205 if status_code == 503: 

206 return TemporaryFailure( 

207 "server_error", 

208 "Service temporarily unavailable (503)", 

209 timedelta(hours=1), 

210 ) 

211 

212 # Caller-safe token vocabulary (PR #6564). Tokens arrive as the 

213 # ``details`` string with ``error_type == "str"`` via 

214 # ``RetryManager.record_attempt`` (or as ``error_type`` directly 

215 # once callers pass it through), so check both fields. Exact match 

216 # only — free-text details must keep falling through to the pattern 

217 # branches below. 

218 for candidate in (error_type, details): 

219 token = (candidate or "").strip().lower() 

220 if not token: 

221 continue 

222 if token in _CLIENT_SAFE_TOKEN_FAILURES: 

223 err, msg, cooldown = _CLIENT_SAFE_TOKEN_FAILURES[token] 

224 return TemporaryFailure(err, msg, cooldown) 

225 if token.startswith("filesystem_error:"): 

226 return TemporaryFailure( 

227 "filesystem_error", 

228 "Filesystem error", 

229 timedelta(hours=1), 

230 ) 

231 if token.startswith("download_error:"): 

232 cls_name = token.split(":", 1)[1].strip() 

233 if "timeout" in cls_name or "timed out" in cls_name: 

234 return TemporaryFailure( 

235 "timeout", "Request timed out", timedelta(minutes=30) 

236 ) 

237 if ( 

238 "connection" in cls_name 

239 or "network" in cls_name 

240 or "reset" in cls_name 

241 ): 

242 return TemporaryFailure( 

243 "network_error", 

244 "Network connectivity issue", 

245 timedelta(minutes=5), 

246 ) 

247 return TemporaryFailure( 

248 f"download_error:{cls_name}", 

249 f"Download failed ({cls_name})", 

250 timedelta(hours=1), 

251 ) 

252 

253 # Error message pattern classifications 

254 error_lower = error_type.lower() 

255 details_lower = details.lower() 

256 

257 # GenericDownloader's neutral diagnostic identifies a format mismatch; 

258 # it is not evidence that the source itself is inaccessible. Keep this 

259 # exact so actual access failures retain their existing classifications. 

260 # download_service._fallback_pdf_extraction (the download_as_text path) 

261 # wraps the downloader's reason as "PDF extraction failed: {reason}" 

262 # before it reaches here, so strip that one known wrapper prefix and 

263 # still require an exact match on what's left. 

264 html_not_pdf_reason = "source is an html page, not a pdf" 

265 pdf_extraction_wrapper = "pdf extraction failed: " 

266 unwrapped_details_lower = ( 

267 details_lower[len(pdf_extraction_wrapper) :] 

268 if details_lower.startswith(pdf_extraction_wrapper) 

269 else details_lower 

270 ) 

271 if unwrapped_details_lower == html_not_pdf_reason: 

272 return TemporaryFailure( 

273 "html_not_pdf", 

274 "Source is an HTML page, not a PDF", 

275 timedelta(hours=1), 

276 ) 

277 

278 # arXiv specific patterns 

279 if "arxiv" in error_lower or "arxiv" in details_lower: 

280 if "recaptcha" in details_lower or "captcha" in details_lower: 

281 return TemporaryFailure( 

282 "recaptcha_protection", 

283 "Anti-bot protection active, retry after 3 days", 

284 timedelta(days=3), 

285 ) 

286 if "not a pdf file" in details_lower: 

287 return PermanentFailure( 

288 "incompatible_format", "Content is not a PDF file" 

289 ) 

290 if ( 

291 "html" in details_lower 

292 and "application/pdf" not in details_lower 

293 ): 

294 return PermanentFailure( 

295 "incompatible_format", 

296 "Content returned HTML instead of PDF", 

297 ) 

298 

299 # Login/subscription/paywall errors — these are permanent 

300 if any( 

301 pattern in details_lower 

302 for pattern in [ 

303 "requires login", 

304 "subscription", 

305 "paywall", 

306 "requires authentication", 

307 ] 

308 ): 

309 return PermanentFailure( 

310 "paywall_or_login", 

311 "Requires login or subscription", 

312 ) 

313 

314 # HTML content instead of PDF (non-arXiv) — permanent 

315 if "text/html" in details_lower and ( 315 ↛ 319line 315 didn't jump to line 319 because the condition on line 315 was never true

316 "unexpected content type" in details_lower 

317 or "not a pdf" in details_lower 

318 ): 

319 return PermanentFailure( 

320 "incompatible_format", 

321 "Content returned HTML instead of PDF", 

322 ) 

323 

324 # Access denied patterns in error messages (without status code) 

325 if ( 325 ↛ 330line 325 didn't jump to line 330 because the condition on line 325 was never true

326 "access denied" in details_lower 

327 or "403 forbidden" in details_lower 

328 or "http 403" in details_lower 

329 ): 

330 return PermanentFailure( 

331 "forbidden", "Access denied based on error message" 

332 ) 

333 

334 # Queued/processing patterns in error messages (without status code) 

335 if ( 

336 "error code 202" in details_lower 

337 or "http 202" in details_lower 

338 or "status code 202" in details_lower 

339 ): 

340 return TemporaryFailure( 

341 "processing", 

342 "Request accepted but not yet ready based on error message", 

343 timedelta(minutes=30), 

344 ) 

345 

346 # Common timeout and network errors 

347 if "timeout" in error_lower or "timed out" in details_lower: 

348 return TemporaryFailure( 

349 "timeout", "Request timed out", timedelta(minutes=30) 

350 ) 

351 if "connection" in error_lower or "network" in error_lower: 

352 return TemporaryFailure( 

353 "network_error", 

354 "Network connectivity issue", 

355 timedelta(minutes=5), 

356 ) 

357 

358 # Fetch that failed after a URL was resolved; last so narrower branches win 

359 if "download failed" in details_lower: 

360 return TemporaryFailure( 

361 "download_failed", 

362 "Resolved URL could not be downloaded", 

363 timedelta(hours=1), 

364 ) 

365 

366 # Default to temporary failure with 1-hour cooldown 

367 logger.warning( 

368 f"[FAILURE_CLASSIFIER] Unclassified error: {error_type} - " 

369 f"{failure_reason_for_log(details)}" 

370 ) 

371 return TemporaryFailure( 

372 "unknown_error", f"Unknown error: {error_type}", timedelta(hours=1) 

373 ) 

374 

375 def classify_from_exception( 

376 self, exception: Exception, url: str = "" 

377 ) -> BaseFailure: 

378 """Classify failure from exception object""" 

379 error_type = type(exception).__name__ 

380 details = str(exception) 

381 return self.classify_failure(error_type, details=details, url=url)