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
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-20 16:46 -0600
1"""HTTP service resource provider."""
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
13from dataknobs_fsm.functions.base import ResourceError
14from dataknobs_fsm.resources.base import (
15 BaseResourceProvider,
16 ResourceHealth,
17 ResourceStatus,
18)
21@dataclass
22class HTTPSession:
23 """HTTP session with configuration and state."""
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
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
38 def is_circuit_open(self) -> bool:
39 """Check if circuit breaker is open.
41 Returns:
42 True if circuit is open and requests should be blocked.
43 """
44 if not self.circuit_open:
45 return False
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
55 return True
57 def record_success(self) -> None:
58 """Record a successful request."""
59 self.failure_count = 0
60 self.circuit_open = False
62 def record_failure(self) -> None:
63 """Record a failed request."""
64 self.failure_count += 1
65 self.last_failure_time = time.time()
67 if self.failure_count >= self.failure_threshold:
68 self.circuit_open = True
71class HTTPServiceResource(BaseResourceProvider):
72 """HTTP service resource provider with session management."""
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.
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)
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
103 # Set up authentication headers if provided
104 if self.auth:
105 self._setup_auth()
107 self._sessions = {}
108 self.status = ResourceStatus.IDLE
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
131 def acquire(self, **kwargs) -> HTTPSession:
132 """Acquire an HTTP session.
134 Args:
135 **kwargs: Session configuration overrides.
137 Returns:
138 HTTPSession instance.
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 )
152 session_id = id(session)
153 self._sessions[session_id] = session
154 self._resources.append(session)
156 self.status = ResourceStatus.ACTIVE
157 return session
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
166 def release(self, resource: Any) -> None:
167 """Release an HTTP session.
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]
177 if resource in self._resources:
178 self._resources.remove(resource)
180 if not self._resources:
181 self.status = ResourceStatus.IDLE
183 def validate(self, resource: Any) -> bool:
184 """Validate an HTTP session.
186 Args:
187 resource: The HTTPSession to validate.
189 Returns:
190 True if the session is valid.
191 """
192 if not isinstance(resource, HTTPSession):
193 return False
195 # Check if circuit breaker is open
196 if resource.is_circuit_open():
197 return False
199 return True
201 def health_check(self) -> ResourceHealth:
202 """Check HTTP service health.
204 Returns:
205 Health status.
206 """
207 session = None
208 try:
209 session = self.acquire()
211 # Try a simple HEAD or GET request to base URL
212 response = self._request(session, "HEAD", "/")
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
221 except Exception:
222 self.metrics.record_health_check(False)
223 return ResourceHealth.UNHEALTHY
224 finally:
225 if session:
226 self.release(session)
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.
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.
247 Returns:
248 Response dictionary with status, headers, and body.
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 )
259 url = urljoin(session.base_url, path)
260 request_headers = {**session.headers}
261 if headers:
262 request_headers.update(headers)
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
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 )
285 # Execute request
286 with urllib.request.urlopen(req, timeout=session.timeout) as response:
287 # Read response
288 body = response.read()
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
296 result = {
297 "status": response.status,
298 "headers": dict(response.headers),
299 "body": body_data
300 }
302 session.record_success()
303 return result
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))
319 except Exception as e:
320 last_error = e
321 if attempt < session.max_retries - 1:
322 time.sleep(session.retry_delay * (attempt + 1))
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 )
332 @contextmanager
333 def session_context(self, **kwargs):
334 """Context manager for HTTP session.
336 Args:
337 **kwargs: Session configuration.
339 Yields:
340 HTTPSession instance.
341 """
342 session = self.acquire(**kwargs)
343 try:
344 yield session
345 finally:
346 self.release(session)
348 def get(
349 self,
350 path: str,
351 session: HTTPSession | None = None,
352 **kwargs
353 ) -> Dict[str, Any]:
354 """Execute GET request.
356 Args:
357 path: Request path.
358 session: Optional session to use.
359 **kwargs: Additional parameters.
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)
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.
378 Args:
379 path: Request path.
380 data: Request body.
381 session: Optional session to use.
382 **kwargs: Additional parameters.
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)
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.
401 Args:
402 path: Request path.
403 data: Request body.
404 session: Optional session to use.
405 **kwargs: Additional parameters.
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)
415 def delete(
416 self,
417 path: str,
418 session: HTTPSession | None = None,
419 **kwargs
420 ) -> Dict[str, Any]:
421 """Execute DELETE request.
423 Args:
424 path: Request path.
425 session: Optional session to use.
426 **kwargs: Additional parameters.
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)