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

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

2

3 import json

4 import time

5 from contextlib import contextmanager

6 from dataclasses import dataclass, field as dataclass_field

7 from typing import Any, Dict, Union

8 from urllib.parse import urljoin

9 import urllib.request

10 import urllib.error

11 import urllib.parse

12

13 from dataknobs_fsm.functions.base import ResourceError

14 from dataknobs_fsm.resources.base import (

15 BaseResourceProvider,

16 ResourceHealth,

17 ResourceStatus,

18)

19

20

21@dataclass

22 class 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

71 class 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)