Coverage for src/local_deep_research/news/exceptions.py: 100%

56 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-19 23:35 +0000

1""" 

2Custom exceptions for the news API module. 

3 

4These exceptions are used to provide structured error handling 

5that can be caught by Flask error handlers and converted to 

6appropriate JSON responses. 

7""" 

8 

9from typing import Optional, Dict, Any 

10 

11from ..security import sanitize_error_details, sanitize_error_for_client 

12 

13 

14class NewsAPIException(Exception): 

15 """Base exception for all news API related errors.""" 

16 

17 def __init__( 

18 self, 

19 message: str, 

20 status_code: int = 500, 

21 error_code: Optional[str] = None, 

22 details: Optional[Dict[str, Any]] = None, 

23 ): 

24 """ 

25 Initialize the news API exception. 

26 

27 Args: 

28 message: Human-readable error message 

29 status_code: HTTP status code for the error 

30 error_code: Machine-readable error code for API consumers 

31 details: Additional error details/context 

32 """ 

33 super().__init__(message) 

34 self.message = message 

35 self.status_code = status_code 

36 self.error_code = error_code or self.__class__.__name__ 

37 self.details = details or {} 

38 

39 def to_dict(self) -> Dict[str, Any]: 

40 """Convert exception to dictionary for JSON response. 

41 

42 This dict is ``jsonify``-ed straight to the client by the two 

43 ``@errorhandler(NewsAPIException)`` handlers (``app_factory`` and 

44 ``news_routes``). The primary defence against leaking raw exception 

45 text is at the raise sites in ``news/api.py``, which pass curated 

46 generic messages instead of ``str(e)``. As a boundary backstop, the 

47 outgoing fields are scrubbed for credential shapes so that a future 

48 raise site, subclass, or external caller that lets a credential-bearing 

49 string in has it redacted before it ships: 

50 

51 * ``message`` — through ``sanitize_error_for_client`` (credential 

52 redaction + control-char strip + 200-char cap; it is prose meant for 

53 display). 

54 * ``details`` string leaves — through ``sanitize_error_details`` (the 

55 shared helper, also used by ``WebAPIException``). 

56 

57 Redaction is credential-*shape* based (``Bearer``/``Authorization``, 

58 ``?api_key=``-style query params, known token prefixes, ``http(s)`` 

59 URL userinfo), so legitimate text — an egress-policy rejection reason, 

60 a user's search ``query`` — is unchanged. It deliberately does NOT try 

61 to catch DSN userinfo (``postgresql://user:pass@``), SQL, schema, or 

62 filesystem paths; the reliable defence for raw DB errors is the 

63 curated-constant discipline at the raise sites (#4843), not this 

64 backstop. 

65 

66 ``error_code`` / ``status_code`` are a machine-readable contract and 

67 are passed through untouched. The raw ``self.message`` / ``self.details`` 

68 are not mutated — server-side diagnosis via ``logger.exception`` keeps 

69 the full text. 

70 """ 

71 result = { 

72 "error": sanitize_error_for_client(self.message), 

73 "error_code": self.error_code, 

74 "status_code": self.status_code, 

75 } 

76 if self.details: 

77 result["details"] = sanitize_error_details(self.details) 

78 return result 

79 

80 

81class NewsFeatureDisabledException(NewsAPIException): 

82 """Raised when the news feature is disabled in settings.""" 

83 

84 def __init__(self, message: str = "News system is disabled"): 

85 super().__init__(message, status_code=503, error_code="NEWS_DISABLED") 

86 

87 

88class InvalidLimitException(NewsAPIException): 

89 """Raised when an invalid limit parameter is provided.""" 

90 

91 def __init__(self, limit: int): 

92 super().__init__( 

93 f"Invalid limit: {limit}. Limit must be at least 1", 

94 status_code=400, 

95 error_code="INVALID_LIMIT", 

96 details={"provided_limit": limit, "min_limit": 1}, 

97 ) 

98 

99 

100class SubscriptionNotFoundException(NewsAPIException): 

101 """Raised when a requested subscription is not found.""" 

102 

103 def __init__(self, subscription_id: str): 

104 super().__init__( 

105 f"Subscription not found: {subscription_id}", 

106 status_code=404, 

107 error_code="SUBSCRIPTION_NOT_FOUND", 

108 details={"subscription_id": subscription_id}, 

109 ) 

110 

111 

112class SubscriptionCreationException(NewsAPIException): 

113 """Raised when subscription creation fails.""" 

114 

115 def __init__(self, message: str, details: Optional[Dict[str, Any]] = None): 

116 super().__init__( 

117 f"Failed to create subscription: {message}", 

118 status_code=500, 

119 error_code="SUBSCRIPTION_CREATE_FAILED", 

120 details=details, 

121 ) 

122 

123 

124class SubscriptionUpdateException(NewsAPIException): 

125 """Raised when subscription update fails.""" 

126 

127 def __init__(self, subscription_id: str, message: str): 

128 super().__init__( 

129 f"Failed to update subscription {subscription_id}: {message}", 

130 status_code=500, 

131 error_code="SUBSCRIPTION_UPDATE_FAILED", 

132 details={"subscription_id": subscription_id}, 

133 ) 

134 

135 

136class SubscriptionDeletionException(NewsAPIException): 

137 """Raised when subscription deletion fails.""" 

138 

139 def __init__(self, subscription_id: str, message: str): 

140 super().__init__( 

141 f"Failed to delete subscription {subscription_id}: {message}", 

142 status_code=500, 

143 error_code="SUBSCRIPTION_DELETE_FAILED", 

144 details={"subscription_id": subscription_id}, 

145 ) 

146 

147 

148class DatabaseAccessException(NewsAPIException): 

149 """Raised when database access fails.""" 

150 

151 def __init__(self, operation: str, message: str): 

152 super().__init__( 

153 f"Database error during {operation}: {message}", 

154 status_code=500, 

155 error_code="DATABASE_ERROR", 

156 details={"operation": operation}, 

157 ) 

158 

159 

160class NewsFeedGenerationException(NewsAPIException): 

161 """Raised when news feed generation fails.""" 

162 

163 def __init__(self, message: str, user_id: Optional[str] = None): 

164 details = {} 

165 if user_id: 

166 details["user_id"] = user_id 

167 super().__init__( 

168 f"Failed to generate news feed: {message}", 

169 status_code=500, 

170 error_code="FEED_GENERATION_FAILED", 

171 details=details, 

172 ) 

173 

174 

175class ResearchProcessingException(NewsAPIException): 

176 """Raised when processing research items for news fails.""" 

177 

178 def __init__(self, message: str, research_id: Optional[str] = None): 

179 details = {} 

180 if research_id: 

181 details["research_id"] = research_id 

182 super().__init__( 

183 f"Failed to process research item: {message}", 

184 status_code=500, 

185 error_code="RESEARCH_PROCESSING_FAILED", 

186 details=details, 

187 ) 

188 

189 

190class NotImplementedException(NewsAPIException): 

191 """Raised when a feature is not yet implemented.""" 

192 

193 def __init__(self, feature: str): 

194 super().__init__( 

195 f"Feature not yet implemented: {feature}", 

196 status_code=501, 

197 error_code="NOT_IMPLEMENTED", 

198 details={"feature": feature}, 

199 ) 

200 

201 

202class InvalidParameterException(NewsAPIException): 

203 """Raised when invalid parameters are provided to API functions.""" 

204 

205 def __init__(self, parameter: str, value: Any, message: str): 

206 super().__init__( 

207 f"Invalid parameter '{parameter}': {message}", 

208 status_code=400, 

209 error_code="INVALID_PARAMETER", 

210 details={"parameter": parameter, "value": value}, 

211 ) 

212 

213 

214class SchedulerNotificationException(NewsAPIException): 

215 """Raised when scheduler notification fails (non-critical).""" 

216 

217 def __init__(self, action: str, message: str): 

218 super().__init__( 

219 f"Failed to notify scheduler about {action}: {message}", 

220 status_code=500, 

221 error_code="SCHEDULER_NOTIFICATION_FAILED", 

222 details={"action": action}, 

223 )