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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""
2Failure Classification System with Inheritance
4Provides base classes and specific failure types for download attempts.
5Uses inheritance to organize different failure categories and their retry behavior.
6"""
8from abc import ABC
9from datetime import datetime, timedelta, UTC
10from typing import Optional
12from loguru import logger
14from ...security import (
15 failure_reason_for_log,
16 url_authority_without_userinfo,
17)
20class BaseFailure(ABC):
21 """Base class for all failure types with common functionality"""
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.
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)
42 logger.debug(
43 f"Created {self.__class__.__name__}: {error_type} - {message}"
44 )
46 def is_permanent(self) -> bool:
47 """Check if this is a permanent failure (never retry)"""
48 return self.retry_after is None
51class PermanentFailure(BaseFailure):
52 """Resources that should never be retried"""
54 def __init__(self, error_type: str, message: str):
55 super().__init__(error_type, message, retry_after=None)
58class TemporaryFailure(BaseFailure):
59 """Resources that can be retried after cooldown"""
61 def __init__(self, error_type: str, message: str, cooldown: timedelta):
62 super().__init__(error_type, message, retry_after=cooldown)
65class RateLimitFailure(TemporaryFailure):
66 """Domain-specific rate limit handling with longer cooldowns"""
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 }
85 cooldown = domain_cooldowns.get(domain, domain_cooldowns["default"])
86 message = f"Rate limited by {domain}"
87 if details:
88 message += f" - {details}"
90 super().__init__("rate_limited", message, cooldown)
91 self.domain = domain
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}
160class FailureClassifier:
161 """Classifies download failures into appropriate types based on error patterns"""
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.
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
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 )
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 )
253 # Error message pattern classifications
254 error_lower = error_type.lower()
255 details_lower = details.lower()
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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)