Coverage for src/dataknobs_fsm/resources/http.py: 0%

176 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-20 16:46 -0600

1"""HTTP service resource provider.""" 

2 

3import json 

4import time 

5from contextlib import contextmanager 

6from dataclasses import dataclass, field as dataclass_field 

7from typing import Any, Dict, Union 

8from urllib.parse import urljoin 

9import urllib.request 

10import urllib.error 

11import urllib.parse 

12 

13from dataknobs_fsm.functions.base import ResourceError 

14from dataknobs_fsm.resources.base import ( 

15 BaseResourceProvider, 

16 ResourceHealth, 

17 ResourceStatus, 

18) 

19 

20 

21@dataclass 

22class HTTPSession: 

23 """HTTP session with configuration and state.""" 

24 

25 base_url: str 

26 headers: Dict[str, str] = dataclass_field(default_factory=dict) 

27 timeout: float = 30.0 

28 max_retries: int = 3 

29 retry_delay: float = 1.0 

30 

31 # Circuit breaker state 

32 failure_count: int = 0 

33 failure_threshold: int = 5 

34 last_failure_time: float | None = None 

35 circuit_open: bool = False 

36 circuit_half_open_after: float = 60.0 # seconds 

37 

38 def is_circuit_open(self) -> bool: 

39 """Check if circuit breaker is open. 

40  

41 Returns: 

42 True if circuit is open and requests should be blocked. 

43 """ 

44 if not self.circuit_open: 

45 return False 

46 

47 # Check if we should transition to half-open 

48 if self.last_failure_time: 

49 elapsed = time.time() - self.last_failure_time 

50 if elapsed > self.circuit_half_open_after: 

51 # Try half-open state 

52 self.circuit_open = False 

53 return False 

54 

55 return True 

56 

57 def record_success(self) -> None: 

58 """Record a successful request.""" 

59 self.failure_count = 0 

60 self.circuit_open = False 

61 

62 def record_failure(self) -> None: 

63 """Record a failed request.""" 

64 self.failure_count += 1 

65 self.last_failure_time = time.time() 

66 

67 if self.failure_count >= self.failure_threshold: 

68 self.circuit_open = True 

69 

70 

71class HTTPServiceResource(BaseResourceProvider): 

72 """HTTP service resource provider with session management.""" 

73 

74 def __init__( 

75 self, 

76 name: str, 

77 base_url: str, 

78 headers: Dict[str, str] | None = None, 

79 auth: Dict[str, str] | None = None, 

80 timeout: float = 30.0, 

81 max_retries: int = 3, 

82 **config 

83 ): 

84 """Initialize HTTP service resource. 

85  

86 Args: 

87 name: Resource name. 

88 base_url: Base URL for the service. 

89 headers: Default headers for requests. 

90 auth: Authentication configuration. 

91 timeout: Request timeout in seconds. 

92 max_retries: Maximum number of retries. 

93 **config: Additional configuration. 

94 """ 

95 super().__init__(name, config) 

96 

97 self.base_url = base_url.rstrip("/") 

98 self.default_headers = headers or {} 

99 self.auth = auth 

100 self.timeout = timeout 

101 self.max_retries = max_retries 

102 

103 # Set up authentication headers if provided 

104 if self.auth: 

105 self._setup_auth() 

106 

107 self._sessions = {} 

108 self.status = ResourceStatus.IDLE 

109 

110 def _setup_auth(self) -> None: 

111 """Set up authentication headers.""" 

112 if self.auth.get("type") == "bearer": 

113 token = self.auth.get("token") 

114 if token: 

115 self.default_headers["Authorization"] = f"Bearer {token}" 

116 elif self.auth.get("type") == "basic": 

117 username = self.auth.get("username") 

118 password = self.auth.get("password") 

119 if username and password: 

120 import base64 

121 credentials = base64.b64encode( 

122 f"{username}:{password}".encode() 

123 ).decode() 

124 self.default_headers["Authorization"] = f"Basic {credentials}" 

125 elif self.auth.get("type") == "api_key": 

126 key_name = self.auth.get("key_name", "X-API-Key") 

127 key_value = self.auth.get("key_value") 

128 if key_value: 

129 self.default_headers[key_name] = key_value 

130 

131 def acquire(self, **kwargs) -> HTTPSession: 

132 """Acquire an HTTP session. 

133  

134 Args: 

135 **kwargs: Session configuration overrides. 

136  

137 Returns: 

138 HTTPSession instance. 

139  

140 Raises: 

141 ResourceError: If acquisition fails. 

142 """ 

143 try: 

144 session = HTTPSession( 

145 base_url=kwargs.get("base_url", self.base_url), 

146 headers={**self.default_headers, **kwargs.get("headers", {})}, 

147 timeout=kwargs.get("timeout", self.timeout), 

148 max_retries=kwargs.get("max_retries", self.max_retries), 

149 retry_delay=kwargs.get("retry_delay", 1.0) 

150 ) 

151 

152 session_id = id(session) 

153 self._sessions[session_id] = session 

154 self._resources.append(session) 

155 

156 self.status = ResourceStatus.ACTIVE 

157 return session 

158 

159 except Exception as e: 

160 self.status = ResourceStatus.ERROR 

161 raise ResourceError( 

162 f"Failed to acquire HTTP session: {e}", 

163 resource_name=self.name 

164 ) from e 

165 

166 def release(self, resource: Any) -> None: 

167 """Release an HTTP session. 

168  

169 Args: 

170 resource: The HTTPSession to release. 

171 """ 

172 if isinstance(resource, HTTPSession): 

173 session_id = id(resource) 

174 if session_id in self._sessions: 

175 del self._sessions[session_id] 

176 

177 if resource in self._resources: 

178 self._resources.remove(resource) 

179 

180 if not self._resources: 

181 self.status = ResourceStatus.IDLE 

182 

183 def validate(self, resource: Any) -> bool: 

184 """Validate an HTTP session. 

185  

186 Args: 

187 resource: The HTTPSession to validate. 

188  

189 Returns: 

190 True if the session is valid. 

191 """ 

192 if not isinstance(resource, HTTPSession): 

193 return False 

194 

195 # Check if circuit breaker is open 

196 if resource.is_circuit_open(): 

197 return False 

198 

199 return True 

200 

201 def health_check(self) -> ResourceHealth: 

202 """Check HTTP service health. 

203  

204 Returns: 

205 Health status. 

206 """ 

207 session = None 

208 try: 

209 session = self.acquire() 

210 

211 # Try a simple HEAD or GET request to base URL 

212 response = self._request(session, "HEAD", "/") 

213 

214 if response.get("status", 0) < 500: 

215 self.metrics.record_health_check(True) 

216 return ResourceHealth.HEALTHY 

217 else: 

218 self.metrics.record_health_check(False) 

219 return ResourceHealth.DEGRADED 

220 

221 except Exception: 

222 self.metrics.record_health_check(False) 

223 return ResourceHealth.UNHEALTHY 

224 finally: 

225 if session: 

226 self.release(session) 

227 

228 def _request( 

229 self, 

230 session: HTTPSession, 

231 method: str, 

232 path: str, 

233 data: Union[Dict, bytes] | None = None, 

234 headers: Dict[str, str] | None = None, 

235 **kwargs 

236 ) -> Dict[str, Any]: 

237 """Execute an HTTP request with retry logic. 

238  

239 Args: 

240 session: HTTP session. 

241 method: HTTP method. 

242 path: Request path. 

243 data: Request body data. 

244 headers: Additional headers. 

245 **kwargs: Additional parameters. 

246  

247 Returns: 

248 Response dictionary with status, headers, and body. 

249  

250 Raises: 

251 ResourceError: If request fails after retries. 

252 """ 

253 if session.is_circuit_open(): 

254 raise ResourceError( 

255 "Circuit breaker is open - service unavailable", 

256 resource_name=self.name 

257 ) 

258 

259 url = urljoin(session.base_url, path) 

260 request_headers = {**session.headers} 

261 if headers: 

262 request_headers.update(headers) 

263 

264 # Prepare request data 

265 request_data = None 

266 if data is not None: 

267 if isinstance(data, dict): 

268 request_data = json.dumps(data).encode("utf-8") 

269 if "Content-Type" not in request_headers: 

270 request_headers["Content-Type"] = "application/json" 

271 else: 

272 request_data = data 

273 

274 last_error = None 

275 for attempt in range(session.max_retries): 

276 try: 

277 # Create request 

278 req = urllib.request.Request( 

279 url, 

280 data=request_data, 

281 headers=request_headers, 

282 method=method 

283 ) 

284 

285 # Execute request 

286 with urllib.request.urlopen(req, timeout=session.timeout) as response: 

287 # Read response 

288 body = response.read() 

289 

290 # Try to decode as JSON 

291 try: 

292 body_data = json.loads(body.decode("utf-8")) 

293 except (json.JSONDecodeError, UnicodeDecodeError): 

294 body_data = body 

295 

296 result = { 

297 "status": response.status, 

298 "headers": dict(response.headers), 

299 "body": body_data 

300 } 

301 

302 session.record_success() 

303 return result 

304 

305 except urllib.error.HTTPError as e: 

306 last_error = e 

307 if e.code < 500: 

308 # Client error, don't retry 

309 session.record_failure() 

310 raise ResourceError( 

311 f"HTTP {e.code}: {e.reason}", 

312 resource_name=self.name, 

313 operation=f"{method} {path}" 

314 ) from e 

315 # Server error, retry 

316 if attempt < session.max_retries - 1: 

317 time.sleep(session.retry_delay * (attempt + 1)) 

318 

319 except Exception as e: 

320 last_error = e 

321 if attempt < session.max_retries - 1: 

322 time.sleep(session.retry_delay * (attempt + 1)) 

323 

324 # All retries failed 

325 session.record_failure() 

326 raise ResourceError( 

327 f"Request failed after {session.max_retries} retries: {last_error}", 

328 resource_name=self.name, 

329 operation=f"{method} {path}" 

330 ) 

331 

332 @contextmanager 

333 def session_context(self, **kwargs): 

334 """Context manager for HTTP session. 

335  

336 Args: 

337 **kwargs: Session configuration. 

338  

339 Yields: 

340 HTTPSession instance. 

341 """ 

342 session = self.acquire(**kwargs) 

343 try: 

344 yield session 

345 finally: 

346 self.release(session) 

347 

348 def get( 

349 self, 

350 path: str, 

351 session: HTTPSession | None = None, 

352 **kwargs 

353 ) -> Dict[str, Any]: 

354 """Execute GET request. 

355  

356 Args: 

357 path: Request path. 

358 session: Optional session to use. 

359 **kwargs: Additional parameters. 

360  

361 Returns: 

362 Response dictionary. 

363 """ 

364 if session is None: 

365 with self.session_context() as ctx_session: 

366 return self._request(ctx_session, "GET", path, **kwargs) 

367 return self._request(session, "GET", path, **kwargs) 

368 

369 def post( 

370 self, 

371 path: str, 

372 data: Union[Dict, bytes] | None = None, 

373 session: HTTPSession | None = None, 

374 **kwargs 

375 ) -> Dict[str, Any]: 

376 """Execute POST request. 

377  

378 Args: 

379 path: Request path. 

380 data: Request body. 

381 session: Optional session to use. 

382 **kwargs: Additional parameters. 

383  

384 Returns: 

385 Response dictionary. 

386 """ 

387 if session is None: 

388 with self.session_context() as ctx_session: 

389 return self._request(ctx_session, "POST", path, data, **kwargs) 

390 return self._request(session, "POST", path, data, **kwargs) 

391 

392 def put( 

393 self, 

394 path: str, 

395 data: Union[Dict, bytes] | None = None, 

396 session: HTTPSession | None = None, 

397 **kwargs 

398 ) -> Dict[str, Any]: 

399 """Execute PUT request. 

400  

401 Args: 

402 path: Request path. 

403 data: Request body. 

404 session: Optional session to use. 

405 **kwargs: Additional parameters. 

406  

407 Returns: 

408 Response dictionary. 

409 """ 

410 if session is None: 

411 with self.session_context() as ctx_session: 

412 return self._request(ctx_session, "PUT", path, data, **kwargs) 

413 return self._request(session, "PUT", path, data, **kwargs) 

414 

415 def delete( 

416 self, 

417 path: str, 

418 session: HTTPSession | None = None, 

419 **kwargs 

420 ) -> Dict[str, Any]: 

421 """Execute DELETE request. 

422  

423 Args: 

424 path: Request path. 

425 session: Optional session to use. 

426 **kwargs: Additional parameters. 

427  

428 Returns: 

429 Response dictionary. 

430 """ 

431 if session is None: 

432 with self.session_context() as ctx_session: 

433 return self._request(ctx_session, "DELETE", path, **kwargs) 

434 return self._request(session, "DELETE", path, **kwargs)