mcp-code-indexer 2.0.2__py3-none-any.whl → 2.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,303 @@
1
+ """
2
+ Custom exception hierarchy for SQLite errors with retry classification.
3
+
4
+ This module provides structured error handling for database operations,
5
+ with specific exceptions for different types of SQLite errors and
6
+ comprehensive error context for monitoring and debugging.
7
+ """
8
+
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Dict, Optional
11
+
12
+
13
+ class DatabaseError(Exception):
14
+ """Base exception for all database-related errors."""
15
+
16
+ def __init__(self, message: str, operation_name: str = "",
17
+ error_context: Optional[Dict[str, Any]] = None):
18
+ self.message = message
19
+ self.operation_name = operation_name
20
+ self.error_context = error_context or {}
21
+ self.timestamp = datetime.now(timezone.utc)
22
+ super().__init__(f"{operation_name}: {message}" if operation_name else message)
23
+
24
+ def to_dict(self) -> Dict[str, Any]:
25
+ """Convert exception to dictionary for structured logging."""
26
+ return {
27
+ "error_type": self.__class__.__name__,
28
+ "message": self.message,
29
+ "operation_name": self.operation_name,
30
+ "timestamp": self.timestamp.isoformat(),
31
+ "error_context": self.error_context
32
+ }
33
+
34
+
35
+ class DatabaseLockError(DatabaseError):
36
+ """Exception for SQLite database locking issues that are retryable."""
37
+
38
+ def __init__(self, message: str, retry_count: int = 0, operation_name: str = "",
39
+ last_attempt: Optional[datetime] = None, lock_type: str = "unknown"):
40
+ self.retry_count = retry_count
41
+ self.last_attempt = last_attempt or datetime.now(timezone.utc)
42
+ self.lock_type = lock_type # 'read', 'write', 'exclusive'
43
+
44
+ error_context = {
45
+ "retry_count": retry_count,
46
+ "last_attempt": self.last_attempt.isoformat(),
47
+ "lock_type": lock_type,
48
+ "retryable": True
49
+ }
50
+
51
+ super().__init__(message, operation_name, error_context)
52
+
53
+
54
+ class DatabaseBusyError(DatabaseError):
55
+ """Exception for SQLite database busy errors that are retryable."""
56
+
57
+ def __init__(self, message: str, operation_name: str = "",
58
+ busy_timeout: float = 0.0, resource_type: str = "connection"):
59
+ self.busy_timeout = busy_timeout
60
+ self.resource_type = resource_type # 'connection', 'transaction', 'table'
61
+
62
+ error_context = {
63
+ "busy_timeout": busy_timeout,
64
+ "resource_type": resource_type,
65
+ "retryable": True
66
+ }
67
+
68
+ super().__init__(message, operation_name, error_context)
69
+
70
+
71
+ class DatabaseConnectionError(DatabaseError):
72
+ """Exception for database connection issues."""
73
+
74
+ def __init__(self, message: str, operation_name: str = "",
75
+ connection_info: Optional[Dict[str, Any]] = None):
76
+ self.connection_info = connection_info or {}
77
+
78
+ error_context = {
79
+ "connection_info": self.connection_info,
80
+ "retryable": False # Connection errors usually indicate config issues
81
+ }
82
+
83
+ super().__init__(message, operation_name, error_context)
84
+
85
+
86
+ class DatabaseSchemaError(DatabaseError):
87
+ """Exception for database schema-related errors."""
88
+
89
+ def __init__(self, message: str, operation_name: str = "",
90
+ schema_version: Optional[str] = None, migration_info: Optional[Dict] = None):
91
+ self.schema_version = schema_version
92
+ self.migration_info = migration_info or {}
93
+
94
+ error_context = {
95
+ "schema_version": schema_version,
96
+ "migration_info": self.migration_info,
97
+ "retryable": False # Schema errors require manual intervention
98
+ }
99
+
100
+ super().__init__(message, operation_name, error_context)
101
+
102
+
103
+ class DatabaseIntegrityError(DatabaseError):
104
+ """Exception for database integrity constraint violations."""
105
+
106
+ def __init__(self, message: str, operation_name: str = "",
107
+ constraint_type: str = "unknown", affected_table: str = ""):
108
+ self.constraint_type = constraint_type # 'primary_key', 'foreign_key', 'unique', 'check'
109
+ self.affected_table = affected_table
110
+
111
+ error_context = {
112
+ "constraint_type": constraint_type,
113
+ "affected_table": affected_table,
114
+ "retryable": False # Integrity errors indicate data issues
115
+ }
116
+
117
+ super().__init__(message, operation_name, error_context)
118
+
119
+
120
+ class DatabaseTimeoutError(DatabaseError):
121
+ """Exception for database operation timeouts."""
122
+
123
+ def __init__(self, message: str, operation_name: str = "",
124
+ timeout_seconds: float = 0.0, operation_type: str = "unknown"):
125
+ self.timeout_seconds = timeout_seconds
126
+ self.operation_type = operation_type # 'read', 'write', 'transaction'
127
+
128
+ error_context = {
129
+ "timeout_seconds": timeout_seconds,
130
+ "operation_type": operation_type,
131
+ "retryable": True # Timeouts might be transient
132
+ }
133
+
134
+ super().__init__(message, operation_name, error_context)
135
+
136
+
137
+ def classify_sqlite_error(error: Exception, operation_name: str = "") -> DatabaseError:
138
+ """
139
+ Classify a raw SQLite error into our structured exception hierarchy.
140
+
141
+ Args:
142
+ error: Original exception from SQLite
143
+ operation_name: Name of the operation that failed
144
+
145
+ Returns:
146
+ Appropriate DatabaseError subclass with context
147
+ """
148
+ error_message = str(error).lower()
149
+ original_message = str(error)
150
+
151
+ # Database locking errors
152
+ if any(msg in error_message for msg in [
153
+ "database is locked",
154
+ "sqlite_locked",
155
+ "attempt to write a readonly database"
156
+ ]):
157
+ lock_type = "write" if "write" in error_message or "readonly" in error_message else "read"
158
+ return DatabaseLockError(
159
+ original_message,
160
+ operation_name=operation_name,
161
+ lock_type=lock_type
162
+ )
163
+
164
+ # Database busy errors
165
+ if any(msg in error_message for msg in [
166
+ "database is busy",
167
+ "sqlite_busy",
168
+ "cannot start a transaction within a transaction"
169
+ ]):
170
+ resource_type = "transaction" if "transaction" in error_message else "connection"
171
+ return DatabaseBusyError(
172
+ original_message,
173
+ operation_name=operation_name,
174
+ resource_type=resource_type
175
+ )
176
+
177
+ # Connection errors
178
+ if any(msg in error_message for msg in [
179
+ "unable to open database",
180
+ "disk i/o error",
181
+ "database disk image is malformed",
182
+ "no such database"
183
+ ]):
184
+ return DatabaseConnectionError(
185
+ original_message,
186
+ operation_name=operation_name
187
+ )
188
+
189
+ # Schema errors
190
+ if any(msg in error_message for msg in [
191
+ "no such table",
192
+ "no such column",
193
+ "table already exists",
194
+ "syntax error"
195
+ ]):
196
+ return DatabaseSchemaError(
197
+ original_message,
198
+ operation_name=operation_name
199
+ )
200
+
201
+ # Integrity constraint errors
202
+ if any(msg in error_message for msg in [
203
+ "unique constraint failed",
204
+ "foreign key constraint failed",
205
+ "primary key constraint failed",
206
+ "check constraint failed"
207
+ ]):
208
+ constraint_type = "unknown"
209
+ if "unique" in error_message:
210
+ constraint_type = "unique"
211
+ elif "foreign key" in error_message:
212
+ constraint_type = "foreign_key"
213
+ elif "primary key" in error_message:
214
+ constraint_type = "primary_key"
215
+ elif "check" in error_message:
216
+ constraint_type = "check"
217
+
218
+ return DatabaseIntegrityError(
219
+ original_message,
220
+ operation_name=operation_name,
221
+ constraint_type=constraint_type
222
+ )
223
+
224
+ # Default to generic database error
225
+ return DatabaseError(
226
+ original_message,
227
+ operation_name=operation_name,
228
+ error_context={"original_error_type": type(error).__name__}
229
+ )
230
+
231
+
232
+ def is_retryable_error(error: Exception) -> bool:
233
+ """
234
+ Determine if an error is retryable based on our classification.
235
+
236
+ Args:
237
+ error: Exception to check
238
+
239
+ Returns:
240
+ True if the error should trigger a retry
241
+ """
242
+ if isinstance(error, DatabaseError):
243
+ return error.error_context.get("retryable", False)
244
+
245
+ # For raw exceptions, use simple classification
246
+ error_message = str(error).lower()
247
+ retryable_patterns = [
248
+ "database is locked",
249
+ "database is busy",
250
+ "sqlite_busy",
251
+ "sqlite_locked",
252
+ "cannot start a transaction within a transaction"
253
+ ]
254
+
255
+ return any(pattern in error_message for pattern in retryable_patterns)
256
+
257
+
258
+ def get_error_classification_stats(errors: list) -> Dict[str, Any]:
259
+ """
260
+ Analyze a list of errors and provide classification statistics.
261
+
262
+ Args:
263
+ errors: List of Exception objects to analyze
264
+
265
+ Returns:
266
+ Dictionary with error classification statistics
267
+ """
268
+ stats = {
269
+ "total_errors": len(errors),
270
+ "error_types": {},
271
+ "retryable_count": 0,
272
+ "non_retryable_count": 0,
273
+ "most_common_errors": {}
274
+ }
275
+
276
+ error_messages = {}
277
+
278
+ for error in errors:
279
+ # Classify error
280
+ classified = classify_sqlite_error(error) if not isinstance(error, DatabaseError) else error
281
+ error_type = type(classified).__name__
282
+
283
+ # Count by type
284
+ stats["error_types"][error_type] = stats["error_types"].get(error_type, 0) + 1
285
+
286
+ # Count retryable vs non-retryable
287
+ if is_retryable_error(classified):
288
+ stats["retryable_count"] += 1
289
+ else:
290
+ stats["non_retryable_count"] += 1
291
+
292
+ # Track common error messages
293
+ message = str(error)
294
+ error_messages[message] = error_messages.get(message, 0) + 1
295
+
296
+ # Find most common error messages
297
+ stats["most_common_errors"] = sorted(
298
+ error_messages.items(),
299
+ key=lambda x: x[1],
300
+ reverse=True
301
+ )[:5]
302
+
303
+ return stats
@@ -0,0 +1,359 @@
1
+ """
2
+ Tenacity-based retry executor for database operations with exponential backoff.
3
+
4
+ This module provides a robust retry executor that replaces the broken async
5
+ context manager retry pattern with proper separation of concerns between
6
+ retry logic and resource management.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ from contextlib import asynccontextmanager
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, timedelta, timezone
14
+ from typing import Any, AsyncIterator, Callable, Dict, Optional, Type, TypeVar, Union
15
+
16
+ import aiosqlite
17
+ from tenacity import (
18
+ AsyncRetrying,
19
+ RetryError,
20
+ stop_after_attempt,
21
+ wait_exponential_jitter,
22
+ retry_if_exception_type,
23
+ before_sleep_log,
24
+ after_log
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ T = TypeVar('T')
30
+
31
+
32
+ @dataclass
33
+ class RetryConfig:
34
+ """Configuration for database retry logic using tenacity."""
35
+ max_attempts: int = 5
36
+ min_wait_seconds: float = 0.1
37
+ max_wait_seconds: float = 2.0
38
+ jitter_max_seconds: float = 0.2 # Max jitter to add
39
+ retry_on_errors: tuple = field(default_factory=lambda: (aiosqlite.OperationalError,))
40
+
41
+
42
+ @dataclass
43
+ class RetryStats:
44
+ """Statistics for retry operations."""
45
+ total_operations: int = 0
46
+ successful_operations: int = 0
47
+ retried_operations: int = 0
48
+ failed_operations: int = 0
49
+ total_attempts: int = 0
50
+ total_retry_time: float = 0.0
51
+ last_operation_time: Optional[datetime] = None
52
+
53
+ @property
54
+ def success_rate(self) -> float:
55
+ """Calculate success rate as percentage."""
56
+ if self.total_operations == 0:
57
+ return 0.0
58
+ return (self.successful_operations / self.total_operations) * 100.0
59
+
60
+ @property
61
+ def retry_rate(self) -> float:
62
+ """Calculate retry rate as percentage."""
63
+ if self.total_operations == 0:
64
+ return 0.0
65
+ return (self.retried_operations / self.total_operations) * 100.0
66
+
67
+ @property
68
+ def average_attempts_per_operation(self) -> float:
69
+ """Calculate average retry attempts per operation."""
70
+ if self.total_operations == 0:
71
+ return 0.0
72
+ return self.total_attempts / self.total_operations
73
+
74
+
75
+ class DatabaseLockError(Exception):
76
+ """Exception for database locking issues with retry context."""
77
+
78
+ def __init__(self, message: str, retry_count: int = 0, operation_name: str = "",
79
+ last_attempt: Optional[datetime] = None):
80
+ self.message = message
81
+ self.retry_count = retry_count
82
+ self.operation_name = operation_name
83
+ self.last_attempt = last_attempt or datetime.now(timezone.utc)
84
+ super().__init__(f"{operation_name}: {message} (after {retry_count} attempts)")
85
+
86
+
87
+ class RetryExecutor:
88
+ """
89
+ Tenacity-based retry executor for database operations.
90
+
91
+ This executor provides robust retry logic with exponential backoff,
92
+ proper error classification, and comprehensive statistics tracking.
93
+ It replaces the broken async context manager retry pattern.
94
+ """
95
+
96
+ def __init__(self, config: Optional[RetryConfig] = None):
97
+ """
98
+ Initialize retry executor.
99
+
100
+ Args:
101
+ config: Retry configuration, uses defaults if None
102
+ """
103
+ self.config = config or RetryConfig()
104
+ self._stats = RetryStats()
105
+ self._operation_start_times: Dict[str, datetime] = {}
106
+
107
+ # Configure tenacity retrying with exponential backoff and jitter
108
+ self._tenacity_retrying = AsyncRetrying(
109
+ stop=stop_after_attempt(self.config.max_attempts),
110
+ wait=wait_exponential_jitter(
111
+ initial=self.config.min_wait_seconds,
112
+ max=self.config.max_wait_seconds,
113
+ jitter=self.config.jitter_max_seconds
114
+ ),
115
+ retry=self._should_retry_exception,
116
+ before_sleep=before_sleep_log(logger, logging.WARNING),
117
+ after=after_log(logger, logging.DEBUG),
118
+ reraise=False
119
+ )
120
+
121
+ async def execute_with_retry(self,
122
+ operation: Callable[[], T],
123
+ operation_name: str = "database_operation") -> T:
124
+ """
125
+ Execute an operation with retry logic.
126
+
127
+ Args:
128
+ operation: Async callable to execute
129
+ operation_name: Name for logging and statistics
130
+
131
+ Returns:
132
+ Result of the operation
133
+
134
+ Raises:
135
+ DatabaseLockError: If all retry attempts fail
136
+ Exception: For non-retryable errors
137
+ """
138
+ self._stats.total_operations += 1
139
+ self._operation_start_times[operation_name] = datetime.now(timezone.utc)
140
+
141
+ attempt_count = 0
142
+ operation_start = datetime.now(timezone.utc)
143
+ operation_had_retries = False
144
+
145
+ try:
146
+ async for attempt in self._tenacity_retrying:
147
+ with attempt:
148
+ attempt_count += 1
149
+ self._stats.total_attempts += 1
150
+
151
+ # Execute the operation
152
+ result = await operation()
153
+
154
+ # Success - update statistics
155
+ operation_time = (datetime.now(timezone.utc) - operation_start).total_seconds()
156
+ self._stats.successful_operations += 1
157
+ self._stats.last_operation_time = datetime.now(timezone.utc)
158
+
159
+ if attempt_count > 1:
160
+ if not operation_had_retries:
161
+ self._stats.retried_operations += 1
162
+ operation_had_retries = True
163
+ self._stats.total_retry_time += operation_time
164
+ logger.info(
165
+ f"Operation '{operation_name}' succeeded after {attempt_count} attempts",
166
+ extra={"structured_data": {
167
+ "retry_success": {
168
+ "operation": operation_name,
169
+ "attempts": attempt_count,
170
+ "total_time_seconds": operation_time
171
+ }
172
+ }}
173
+ )
174
+
175
+ return result
176
+
177
+ except RetryError as e:
178
+ # All retry attempts exhausted
179
+ operation_time = (datetime.now(timezone.utc) - operation_start).total_seconds()
180
+ self._stats.failed_operations += 1
181
+ self._stats.total_retry_time += operation_time
182
+
183
+ original_error = e.last_attempt.exception()
184
+ logger.error(
185
+ f"Operation '{operation_name}' failed after {attempt_count} attempts",
186
+ extra={"structured_data": {
187
+ "retry_exhausted": {
188
+ "operation": operation_name,
189
+ "max_attempts": self.config.max_attempts,
190
+ "total_time_seconds": operation_time,
191
+ "final_error": str(original_error)
192
+ }
193
+ }}
194
+ )
195
+
196
+ raise DatabaseLockError(
197
+ f"Database operation failed after {attempt_count} attempts: {original_error}",
198
+ retry_count=attempt_count,
199
+ operation_name=operation_name,
200
+ last_attempt=datetime.now(timezone.utc)
201
+ )
202
+
203
+ except Exception as e:
204
+ # Non-retryable error on first attempt
205
+ self._stats.failed_operations += 1
206
+ logger.error(
207
+ f"Non-retryable error in '{operation_name}': {e}",
208
+ extra={"structured_data": {
209
+ "immediate_failure": {
210
+ "operation": operation_name,
211
+ "error_type": type(e).__name__,
212
+ "error_message": str(e)
213
+ }
214
+ }}
215
+ )
216
+ raise
217
+
218
+ finally:
219
+ # Clean up tracking
220
+ self._operation_start_times.pop(operation_name, None)
221
+
222
+ @asynccontextmanager
223
+ async def get_connection_with_retry(self,
224
+ connection_factory: Callable[[], AsyncIterator[aiosqlite.Connection]],
225
+ operation_name: str = "database_connection") -> AsyncIterator[aiosqlite.Connection]:
226
+ """
227
+ Get a database connection with retry logic wrapped around the context manager.
228
+
229
+ This method properly separates retry logic from resource management by
230
+ retrying the entire context manager operation, not yielding inside a retry loop.
231
+
232
+ Args:
233
+ connection_factory: Function that returns an async context manager for connections
234
+ operation_name: Name for logging and statistics
235
+
236
+ Yields:
237
+ Database connection
238
+ """
239
+
240
+ async def get_connection():
241
+ # This function will be retried by execute_with_retry
242
+ async with connection_factory() as conn:
243
+ # Store connection for the outer context manager
244
+ return conn
245
+
246
+ # Use execute_with_retry to handle the retry logic
247
+ # We create a connection and store it for the context manager
248
+ connection = await self.execute_with_retry(get_connection, operation_name)
249
+
250
+ try:
251
+ yield connection
252
+ finally:
253
+ # Connection cleanup is handled by the original context manager
254
+ # in the connection_factory, so nothing to do here
255
+ pass
256
+
257
+ def _should_retry_exception(self, retry_state) -> bool:
258
+ """
259
+ Determine if an exception should trigger a retry.
260
+
261
+ This is used by tenacity to decide whether to retry.
262
+
263
+ Args:
264
+ retry_state: Tenacity retry state
265
+
266
+ Returns:
267
+ True if the exception should trigger a retry
268
+ """
269
+ if retry_state.outcome is None:
270
+ return False
271
+
272
+ exception = retry_state.outcome.exception()
273
+ if exception is None:
274
+ return False
275
+
276
+ return self._is_sqlite_retryable_error(exception)
277
+
278
+ def _is_sqlite_retryable_error(self, error: Exception) -> bool:
279
+ """
280
+ Determine if a SQLite error is retryable.
281
+
282
+ Args:
283
+ error: Exception to check
284
+
285
+ Returns:
286
+ True if the error should trigger a retry
287
+ """
288
+ if not isinstance(error, self.config.retry_on_errors):
289
+ return False
290
+
291
+ # Check specific SQLite error messages that indicate transient issues
292
+ error_message = str(error).lower()
293
+ retryable_messages = [
294
+ "database is locked",
295
+ "database is busy",
296
+ "cannot start a transaction within a transaction",
297
+ "sqlite_busy",
298
+ "sqlite_locked"
299
+ ]
300
+
301
+ return any(msg in error_message for msg in retryable_messages)
302
+
303
+ def get_retry_stats(self) -> Dict[str, Any]:
304
+ """
305
+ Get comprehensive retry statistics.
306
+
307
+ Returns:
308
+ Dictionary with retry statistics and performance metrics
309
+ """
310
+ return {
311
+ "total_operations": self._stats.total_operations,
312
+ "successful_operations": self._stats.successful_operations,
313
+ "retried_operations": self._stats.retried_operations,
314
+ "failed_operations": self._stats.failed_operations,
315
+ "total_attempts": self._stats.total_attempts,
316
+ "success_rate_percent": round(self._stats.success_rate, 2),
317
+ "retry_rate_percent": round(self._stats.retry_rate, 2),
318
+ "average_attempts_per_operation": round(self._stats.average_attempts_per_operation, 2),
319
+ "total_retry_time_seconds": round(self._stats.total_retry_time, 3),
320
+ "last_operation_time": self._stats.last_operation_time.isoformat() if self._stats.last_operation_time else None,
321
+ "config": {
322
+ "max_attempts": self.config.max_attempts,
323
+ "min_wait_seconds": self.config.min_wait_seconds,
324
+ "max_wait_seconds": self.config.max_wait_seconds,
325
+ "jitter_max_seconds": self.config.jitter_max_seconds
326
+ }
327
+ }
328
+
329
+ def reset_stats(self) -> None:
330
+ """Reset retry statistics."""
331
+ self._stats = RetryStats()
332
+ self._operation_start_times.clear()
333
+
334
+
335
+ def create_retry_executor(
336
+ max_attempts: int = 5,
337
+ min_wait_seconds: float = 0.1,
338
+ max_wait_seconds: float = 2.0,
339
+ jitter_max_seconds: float = 0.2
340
+ ) -> RetryExecutor:
341
+ """
342
+ Create a configured retry executor for database operations.
343
+
344
+ Args:
345
+ max_attempts: Maximum retry attempts
346
+ min_wait_seconds: Initial delay in seconds
347
+ max_wait_seconds: Maximum delay in seconds
348
+ jitter_max_seconds: Maximum jitter to add to delays
349
+
350
+ Returns:
351
+ Configured RetryExecutor instance
352
+ """
353
+ config = RetryConfig(
354
+ max_attempts=max_attempts,
355
+ min_wait_seconds=min_wait_seconds,
356
+ max_wait_seconds=max_wait_seconds,
357
+ jitter_max_seconds=jitter_max_seconds
358
+ )
359
+ return RetryExecutor(config)