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.
- mcp_code_indexer/ask_handler.py +217 -0
- mcp_code_indexer/claude_api_handler.py +355 -0
- mcp_code_indexer/database/connection_health.py +187 -3
- mcp_code_indexer/database/database.py +94 -68
- mcp_code_indexer/database/exceptions.py +303 -0
- mcp_code_indexer/database/retry_executor.py +359 -0
- mcp_code_indexer/deepask_handler.py +465 -0
- mcp_code_indexer/server/mcp_server.py +79 -12
- {mcp_code_indexer-2.0.2.dist-info → mcp_code_indexer-2.2.0.dist-info}/METADATA +3 -3
- {mcp_code_indexer-2.0.2.dist-info → mcp_code_indexer-2.2.0.dist-info}/RECORD +14 -10
- mcp_code_indexer/database/retry_handler.py +0 -344
- {mcp_code_indexer-2.0.2.dist-info → mcp_code_indexer-2.2.0.dist-info}/WHEEL +0 -0
- {mcp_code_indexer-2.0.2.dist-info → mcp_code_indexer-2.2.0.dist-info}/entry_points.txt +0 -0
- {mcp_code_indexer-2.0.2.dist-info → mcp_code_indexer-2.2.0.dist-info}/licenses/LICENSE +0 -0
- {mcp_code_indexer-2.0.2.dist-info → mcp_code_indexer-2.2.0.dist-info}/top_level.txt +0 -0
@@ -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)
|