Coverage for src/local_deep_research/web_search_engines/rate_limiting/llm/detection.py: 93%
48 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"""
2LLM-specific rate limit error detection.
3"""
5from httpx import TimeoutException as HTTPXTimeout
6from requests.exceptions import Timeout as RequestsTimeout
8from ....security.log_sanitizer import scrub_error
9from ....security.secure_logging import logger
12def is_llm_rate_limit_error(error: BaseException) -> bool:
13 """
14 Detect if an error is a rate limit error from an LLM provider.
16 Args:
17 error: The exception to check
19 Returns:
20 True if this is a rate limit error, False otherwise
21 """
22 error_str = str(error).lower()
23 error_type = type(error).__name__.lower()
25 # Check for explicit HTTP 429 status codes
26 if hasattr(error, "response") and hasattr(error.response, "status_code"):
27 if error.response.status_code == 429:
28 logger.debug(
29 f"Detected HTTP 429 rate limit error: {scrub_error(error)}"
30 )
31 return True
33 # A transport timeout is not a provider throttling response. Its text
34 # can contain retry advice, a duration such as 429 ms, or rate-limit
35 # wording from the operation that timed out. Preserve the concrete
36 # failure type before applying the fallback message heuristics.
37 if isinstance(error, (TimeoutError, HTTPXTimeout, RequestsTimeout)):
38 return False
40 # Check for common rate limit error messages
41 rate_limit_indicators = [
42 "rate limit",
43 "rate_limit",
44 "ratelimit",
45 "too many requests",
46 "quota exceeded",
47 "quota has been exhausted",
48 "resource has been exhausted",
49 "429",
50 "threshold",
51 "try again later",
52 "slow down",
53 ]
55 if any(indicator in error_str for indicator in rate_limit_indicators):
56 logger.debug(
57 f"Detected rate limit error from message: {scrub_error(error)}"
58 )
59 return True
61 # Check for specific provider error types
62 if "ratelimiterror" in error_type or "quotaexceeded" in error_type:
63 logger.debug(f"Detected rate limit error from type: {type(error)}")
64 return True
66 # Check for OpenAI specific rate limit errors
67 if hasattr(error, "__class__") and error.__class__.__module__ == "openai":
68 if error_type in ["ratelimiterror", "apierror"] and "429" in error_str: 68 ↛ 69line 68 didn't jump to line 69 because the condition on line 68 was never true
69 logger.debug(
70 f"Detected OpenAI rate limit error: {scrub_error(error)}"
71 )
72 return True
74 # Check for Anthropic specific rate limit errors
75 if (
76 hasattr(error, "__class__")
77 and "anthropic" in error.__class__.__module__
78 ):
79 if any(x in error_str for x in ["rate_limit", "429", "too many"]):
80 logger.debug(
81 f"Detected Anthropic rate limit error: {scrub_error(error)}"
82 )
83 return True
85 return False
88def extract_retry_after(error: Exception) -> float:
89 """
90 Extract retry-after time from rate limit error if available.
92 Args:
93 error: The rate limit error
95 Returns:
96 Retry after time in seconds, or 0 if not found
97 """
98 # Check for Retry-After header in response
99 if hasattr(error, "response") and hasattr(error.response, "headers"):
100 retry_after = error.response.headers.get("Retry-After")
101 if retry_after:
102 try:
103 return float(retry_after)
104 except ValueError:
105 logger.debug(
106 f"Could not parse Retry-After header: {retry_after}"
107 )
109 # Try to extract from error message
110 error_str = str(error)
112 # Look for patterns like "try again in X seconds"
113 import re
115 patterns = [
116 r"try again in (\d+(?:\.\d+)?)\s*seconds?",
117 r"retry after (\d+(?:\.\d+)?)\s*seconds?",
118 r"wait (\d+(?:\.\d+)?)\s*seconds?",
119 ]
121 for pattern in patterns:
122 match = re.search(pattern, error_str, re.IGNORECASE)
123 if match:
124 try:
125 return float(match.group(1))
126 except ValueError:
127 pass
129 return 0