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

1""" 

2LLM-specific rate limit error detection. 

3""" 

4 

5from httpx import TimeoutException as HTTPXTimeout 

6from requests.exceptions import Timeout as RequestsTimeout 

7 

8from ....security.log_sanitizer import scrub_error 

9from ....security.secure_logging import logger 

10 

11 

12def is_llm_rate_limit_error(error: BaseException) -> bool: 

13 """ 

14 Detect if an error is a rate limit error from an LLM provider. 

15 

16 Args: 

17 error: The exception to check 

18 

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() 

24 

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 

32 

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 

39 

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 ] 

54 

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 

60 

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 

65 

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 

73 

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 

84 

85 return False 

86 

87 

88def extract_retry_after(error: Exception) -> float: 

89 """ 

90 Extract retry-after time from rate limit error if available. 

91 

92 Args: 

93 error: The rate limit error 

94 

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 ) 

108 

109 # Try to extract from error message 

110 error_str = str(error) 

111 

112 # Look for patterns like "try again in X seconds" 

113 import re 

114 

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 ] 

120 

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 

128 

129 return 0