Coverage for src/taipanstack/bridges/db_bridge.py: 100%

114 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-10-13 20:52 +0000

1""" 

2DB Bridge — resilient database wrappers. 

3 

4Wraps SQLAlchemy async engine and Redis async client with 

5TaipanStack's circuit breaker and retry patterns. 

6""" 

7 

8from __future__ import annotations 

9 

10import asyncio 

11import logging 

12from typing import TYPE_CHECKING 

13 

14if TYPE_CHECKING: 

15 import redis.asyncio as aioredis 

16 from sqlalchemy.engine import Result as SQLAlchemyResult 

17 from sqlalchemy.ext.asyncio import AsyncEngine 

18 from sqlalchemy.sql.executable import Executable 

19 

20from taipanstack.core.result import Err, Ok, Result 

21from taipanstack.resilience.circuit_breaker import ( 

22 CircuitBreaker, 

23 CircuitBreakerError, 

24 CircuitState, 

25) 

26from taipanstack.resilience.retry import RetryConfig, calculate_delay 

27 

28logger = logging.getLogger("taipanstack.bridges.db") 

29 

30# --- optional imports ------------------------------------------------------ 

31 

32try: 

33 import sqlalchemy # noqa: F401 

34 from sqlalchemy.ext.asyncio import AsyncSession 

35 

36 _HAS_SQLALCHEMY = True 

37except ImportError: 

38 _HAS_SQLALCHEMY = False 

39 

40try: 

41 import redis.asyncio as aioredis 

42 

43 _HAS_REDIS = True 

44except ImportError: 

45 _HAS_REDIS = False 

46 

47 

48def _breaker_is_open(cb: CircuitBreaker) -> CircuitBreakerError | None: 

49 """Return a ``CircuitBreakerError`` if the breaker is OPEN. 

50 

51 Args: 

52 cb: The circuit breaker to check. 

53 

54 Returns: 

55 Error if open, ``None`` otherwise. 

56 

57 """ 

58 if cb.state is CircuitState.OPEN: 

59 return CircuitBreakerError( 

60 f"Circuit breaker '{cb.name}' is OPEN", 

61 state=cb.state, 

62 ) 

63 return None 

64 

65 

66class ResilientDatabase: 

67 """Wraps a SQLAlchemy async engine with resilience patterns. 

68 

69 Args: 

70 engine: SQLAlchemy ``AsyncEngine`` instance. 

71 circuit_breaker: Optional circuit breaker. 

72 retry_config: Optional retry configuration. 

73 

74 Example: 

75 >>> db = ResilientDatabase(engine, circuit_breaker=breaker) 

76 >>> result = await db.execute(text("SELECT 1")) 

77 

78 """ 

79 

80 def __init__( 

81 self, 

82 engine: AsyncEngine, 

83 *, 

84 circuit_breaker: CircuitBreaker | None = None, 

85 retry_config: RetryConfig | None = None, 

86 ) -> None: 

87 """Initialize the resilient database wrapper. 

88 

89 Args: 

90 engine: SQLAlchemy AsyncEngine. 

91 circuit_breaker: Optional circuit breaker. 

92 retry_config: Optional retry config. 

93 

94 """ 

95 self._engine = engine # type: ignore[misc] 

96 self._circuit_breaker = circuit_breaker 

97 self._retry_config = retry_config 

98 

99 def _check_db_dependencies(self) -> Result[None, Exception]: 

100 if not _HAS_SQLALCHEMY: 

101 return Err( 

102 ImportError( 

103 "sqlalchemy is required for ResilientDatabase. " 

104 "Install with: pip install taipanstack[bridges-db]", 

105 ), 

106 ) 

107 return Ok(None) 

108 

109 def _check_breaker_gate(self) -> Result[None, Exception]: 

110 if self._circuit_breaker is not None: 

111 cb_err = _breaker_is_open(self._circuit_breaker) 

112 if cb_err is not None: 

113 return Err(cb_err) 

114 return Ok(None) 

115 

116 async def _handle_attempt_failure( 

117 self, 

118 exc: Exception, 

119 attempt: int, 

120 max_attempts: int, 

121 ) -> bool: 

122 """Handle a failed execution attempt. 

123 

124 Args: 

125 exc: The exception that occurred. 

126 attempt: The current attempt number. 

127 max_attempts: Maximum number of attempts allowed. 

128 

129 Returns: 

130 True if the operation should be retried, False otherwise. 

131 

132 """ 

133 logger.warning( 

134 "DB execute attempt %d failed: %s", 

135 attempt, 

136 exc, 

137 ) 

138 if self._circuit_breaker is not None: 

139 self._circuit_breaker._record_failure(exc) 

140 if self._retry_config is not None and attempt < max_attempts: 

141 delay = calculate_delay(attempt, self._retry_config) 

142 await asyncio.sleep(min(delay, 3600.0)) 

143 return True 

144 return False 

145 

146 async def _execute_single_attempt( 

147 self, 

148 statement: Executable, 

149 **kwargs: object, 

150 ) -> Result[SQLAlchemyResult[object], Exception]: 

151 try: 

152 async with AsyncSession(self._engine) as session: # type: ignore[misc] 

153 result = await session.execute(statement, **kwargs) # type: ignore[misc] 

154 return Ok(result) # type: ignore[misc] 

155 except Exception as exc: 

156 return Err(exc) 

157 

158 async def _process_attempt( 

159 self, 

160 statement: Executable, 

161 attempt: int, 

162 max_attempts: int, 

163 **kwargs: object, 

164 ) -> Result[SQLAlchemyResult[object], Exception] | tuple[bool, Exception]: 

165 result = await self._execute_single_attempt(statement, **kwargs) # type: ignore[misc] 

166 if isinstance(result, Ok): # type: ignore[misc] 

167 return result # type: ignore[misc] 

168 

169 last_error = result.err_value 

170 should_continue = await self._handle_attempt_failure( 

171 last_error, attempt, max_attempts 

172 ) 

173 return should_continue, last_error 

174 

175 async def _execute_loop( 

176 self, 

177 statement: Executable, 

178 max_attempts: int, 

179 **kwargs: object, 

180 ) -> Result[SQLAlchemyResult[object], Exception]: 

181 """Execute the retry loop for database operations.""" 

182 last_error: Exception = RuntimeError("Database execute failed") 

183 

184 for attempt in range(1, max_attempts + 1): 

185 outcome = await self._process_attempt( # type: ignore[misc] 

186 statement, # type: ignore[misc] 

187 attempt, 

188 max_attempts, 

189 **kwargs, 

190 ) 

191 if not isinstance(outcome, tuple): # type: ignore[misc] 

192 return outcome # type: ignore[misc] 

193 

194 should_continue, last_error = outcome 

195 if not should_continue: 

196 break 

197 

198 return Err(last_error) 

199 

200 async def execute( 

201 self, 

202 statement: Executable, 

203 **kwargs: object, 

204 ) -> Result[SQLAlchemyResult[object], Exception]: 

205 """Execute a SQL statement with resilience. 

206 

207 Args: 

208 statement: SQLAlchemy statement to execute. 

209 **kwargs: Passed to ``session.execute``. 

210 

211 Returns: 

212 ``Ok(result)`` on success, ``Err`` on failure. 

213 

214 """ 

215 dep_result = self._check_db_dependencies() 

216 if isinstance(dep_result, Err): 

217 return dep_result 

218 

219 cb_result = self._check_breaker_gate() 

220 if isinstance(cb_result, Err): 

221 return cb_result 

222 

223 max_attempts = 1 

224 if self._retry_config is not None: 

225 max_attempts = self._retry_config.max_attempts 

226 

227 return await self._execute_loop(statement, max_attempts, **kwargs) # type: ignore[misc] 

228 

229 

230class ResilientRedis: 

231 """Wraps a Redis async client with resilience patterns. 

232 

233 Args: 

234 client: ``redis.asyncio.Redis`` instance. 

235 circuit_breaker: Optional circuit breaker. 

236 

237 Example: 

238 >>> r = ResilientRedis(redis_client, circuit_breaker=breaker) 

239 >>> result = await r.execute("GET", "my_key") 

240 

241 """ 

242 

243 def __init__( 

244 self, 

245 client: aioredis.Redis[bytes | str], 

246 *, 

247 circuit_breaker: CircuitBreaker | None = None, 

248 ) -> None: 

249 """Initialize the resilient Redis wrapper. 

250 

251 Args: 

252 client: Redis async client. 

253 circuit_breaker: Optional circuit breaker. 

254 

255 """ 

256 self._client = client # type: ignore[misc] 

257 self._circuit_breaker = circuit_breaker 

258 

259 def _check_redis_dependencies(self) -> Result[None, Exception]: 

260 if not _HAS_REDIS: 

261 return Err( 

262 ImportError( 

263 "redis is required for ResilientRedis. " 

264 "Install with: pip install taipanstack[bridges-db]", 

265 ), 

266 ) 

267 return Ok(None) 

268 

269 def _check_breaker_gate(self) -> Result[None, Exception]: 

270 if self._circuit_breaker is not None: 

271 cb_err = _breaker_is_open(self._circuit_breaker) 

272 if cb_err is not None: 

273 return Err(cb_err) 

274 return Ok(None) 

275 

276 async def _execute_command( 

277 self, 

278 command: str, 

279 *args: object, 

280 ) -> Result[object, Exception]: 

281 try: 

282 fn = getattr(self._client, command.lower()) # type: ignore[misc] 

283 result = await fn(*args) # type: ignore[misc] 

284 return Ok(result) # type: ignore[misc] 

285 except Exception as exc: 

286 logger.warning("Redis command '%s' failed: %s", command, exc) 

287 if self._circuit_breaker is not None: 

288 self._circuit_breaker._record_failure(exc) 

289 return Err(exc) 

290 

291 async def execute( 

292 self, 

293 command: str, 

294 *args: object, 

295 ) -> Result[object, Exception]: 

296 """Execute a Redis command with resilience. 

297 

298 Args: 

299 command: Redis command name (e.g. ``"GET"``, ``"SET"``). 

300 *args: Command arguments. 

301 

302 Returns: 

303 ``Ok(result)`` on success, ``Err`` on failure. 

304 

305 """ 

306 dep_result = self._check_redis_dependencies() 

307 if isinstance(dep_result, Err): 

308 return dep_result 

309 

310 cb_result = self._check_breaker_gate() 

311 if isinstance(cb_result, Err): 

312 return cb_result 

313 

314 return await self._execute_command(command, *args)