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
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
1"""
2Custom exceptions for the news API module.
4These exceptions are used to provide structured error handling
5that can be caught by Flask error handlers and converted to
6appropriate JSON responses.
7"""
9from typing import Optional, Dict, Any
11from ..security import sanitize_error_details, sanitize_error_for_client
14class NewsAPIException(Exception):
15 """Base exception for all news API related errors."""
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.
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 {}
39 def to_dict(self) -> Dict[str, Any]:
40 """Convert exception to dictionary for JSON response.
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:
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``).
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.
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
81class NewsFeatureDisabledException(NewsAPIException):
82 """Raised when the news feature is disabled in settings."""
84 def __init__(self, message: str = "News system is disabled"):
85 super().__init__(message, status_code=503, error_code="NEWS_DISABLED")
88class InvalidLimitException(NewsAPIException):
89 """Raised when an invalid limit parameter is provided."""
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 )
100class SubscriptionNotFoundException(NewsAPIException):
101 """Raised when a requested subscription is not found."""
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 )
112class SubscriptionCreationException(NewsAPIException):
113 """Raised when subscription creation fails."""
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 )
124class SubscriptionUpdateException(NewsAPIException):
125 """Raised when subscription update fails."""
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 )
136class SubscriptionDeletionException(NewsAPIException):
137 """Raised when subscription deletion fails."""
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 )
148class DatabaseAccessException(NewsAPIException):
149 """Raised when database access fails."""
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 )
160class NewsFeedGenerationException(NewsAPIException):
161 """Raised when news feed generation fails."""
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 )
175class ResearchProcessingException(NewsAPIException):
176 """Raised when processing research items for news fails."""
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 )
190class NotImplementedException(NewsAPIException):
191 """Raised when a feature is not yet implemented."""
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 )
202class InvalidParameterException(NewsAPIException):
203 """Raised when invalid parameters are provided to API functions."""
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 )
214class SchedulerNotificationException(NewsAPIException):
215 """Raised when scheduler notification fails (non-critical)."""
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 )