agentic-fabriq-sdk 0.1.3__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.

Potentially problematic release.


This version of agentic-fabriq-sdk might be problematic. Click here for more details.

@@ -0,0 +1,366 @@
1
+ """
2
+ HTTP transport layer for Agentic Fabric SDK.
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from typing import Any, Dict, Optional
9
+ from urllib.parse import urljoin
10
+
11
+ import httpx
12
+ from opentelemetry import trace
13
+ from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
14
+
15
+ from ..exceptions import (
16
+ AuthenticationError,
17
+ AuthorizationError,
18
+ NotFoundError,
19
+ RateLimitError,
20
+ ServiceUnavailableError,
21
+ UpstreamError,
22
+ ValidationError,
23
+ create_exception_from_response,
24
+ )
25
+
26
+
27
+ class HTTPClient:
28
+ """HTTP client with retries, tracing, and error handling."""
29
+
30
+ def __init__(
31
+ self,
32
+ base_url: str,
33
+ timeout: float = 30.0,
34
+ retries: int = 3,
35
+ backoff_factor: float = 0.5,
36
+ logger: Optional[logging.Logger] = None,
37
+ auth_token: Optional[str] = None,
38
+ user_agent: str = "agentic-fabriq-sdk/1.0.0",
39
+ trace_enabled: bool = True,
40
+ ):
41
+ self.base_url = base_url.rstrip("/")
42
+ self.timeout = timeout
43
+ self.retries = retries
44
+ self.backoff_factor = backoff_factor
45
+ self.logger = logger or logging.getLogger(__name__)
46
+ self.auth_token = auth_token
47
+ self.user_agent = user_agent
48
+ self.trace_enabled = trace_enabled
49
+
50
+ # Configure HTTP client
51
+ self.client = httpx.AsyncClient(
52
+ timeout=httpx.Timeout(timeout),
53
+ headers={
54
+ "User-Agent": user_agent,
55
+ "Accept": "application/json",
56
+ "Content-Type": "application/json",
57
+ },
58
+ )
59
+
60
+ # Enable OpenTelemetry tracing
61
+ if trace_enabled:
62
+ HTTPXClientInstrumentor().instrument_client(self.client)
63
+
64
+ self.tracer = trace.get_tracer(__name__)
65
+
66
+ async def __aenter__(self):
67
+ return self
68
+
69
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
70
+ await self.close()
71
+
72
+ async def close(self):
73
+ """Close the HTTP client."""
74
+ await self.client.aclose()
75
+
76
+ def _build_url(self, path: str) -> str:
77
+ """Build full URL from base URL and path."""
78
+ return urljoin(self.base_url + "/", path.lstrip("/"))
79
+
80
+ def _prepare_headers(self, headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
81
+ """Prepare headers for request."""
82
+ request_headers = headers.copy() if headers else {}
83
+
84
+ if self.auth_token:
85
+ request_headers.setdefault("Authorization", f"Bearer {self.auth_token}")
86
+
87
+ return request_headers
88
+
89
+ async def _make_request(
90
+ self,
91
+ method: str,
92
+ path: str,
93
+ headers: Optional[Dict[str, str]] = None,
94
+ params: Optional[Dict[str, Any]] = None,
95
+ json: Optional[Dict[str, Any]] = None,
96
+ data: Optional[Any] = None,
97
+ files: Optional[Dict[str, Any]] = None,
98
+ **kwargs,
99
+ ) -> httpx.Response:
100
+ """Make HTTP request with retries and error handling."""
101
+ url = self._build_url(path)
102
+ request_headers = self._prepare_headers(headers)
103
+
104
+ # Generate request ID for tracing
105
+ request_id = f"req_{int(time.time() * 1000)}"
106
+ request_headers["X-Request-ID"] = request_id
107
+
108
+ with self.tracer.start_as_current_span(
109
+ f"http_{method.lower()}",
110
+ attributes={
111
+ "http.method": method,
112
+ "http.url": url,
113
+ "http.request_id": request_id,
114
+ },
115
+ ) as span:
116
+ last_exception = None
117
+
118
+ for attempt in range(self.retries + 1):
119
+ try:
120
+ self.logger.debug(
121
+ f"Making {method} request to {url} (attempt {attempt + 1})",
122
+ extra={
123
+ "request_id": request_id,
124
+ "method": method,
125
+ "url": url,
126
+ "attempt": attempt + 1,
127
+ },
128
+ )
129
+
130
+ response = await self.client.request(
131
+ method=method,
132
+ url=url,
133
+ headers=request_headers,
134
+ params=params,
135
+ json=json,
136
+ data=data,
137
+ files=files,
138
+ **kwargs,
139
+ )
140
+
141
+ # Add response attributes to span
142
+ span.set_attributes({
143
+ "http.status_code": response.status_code,
144
+ "http.response_size": len(response.content),
145
+ })
146
+
147
+ self.logger.debug(
148
+ f"Response: {response.status_code}",
149
+ extra={
150
+ "request_id": request_id,
151
+ "status_code": response.status_code,
152
+ "response_time": response.elapsed.total_seconds(),
153
+ },
154
+ )
155
+
156
+ # Handle different status codes
157
+ if response.status_code < 400:
158
+ return response
159
+ elif response.status_code in [429, 502, 503, 504]:
160
+ # Retry on rate limit and server errors
161
+ if attempt < self.retries:
162
+ await self._handle_retry_response(response, attempt)
163
+ continue
164
+
165
+ # Handle client and server errors
166
+ await self._handle_error_response(response, request_id)
167
+
168
+ except httpx.TimeoutException as e:
169
+ last_exception = e
170
+ self.logger.warning(
171
+ f"Request timeout (attempt {attempt + 1})",
172
+ extra={"request_id": request_id, "error": str(e)},
173
+ )
174
+ if attempt < self.retries:
175
+ await asyncio.sleep(self.backoff_factor * (2 ** attempt))
176
+ continue
177
+
178
+ except httpx.NetworkError as e:
179
+ last_exception = e
180
+ self.logger.warning(
181
+ f"Network error (attempt {attempt + 1})",
182
+ extra={"request_id": request_id, "error": str(e)},
183
+ )
184
+ if attempt < self.retries:
185
+ await asyncio.sleep(self.backoff_factor * (2 ** attempt))
186
+ continue
187
+
188
+ except Exception as e:
189
+ last_exception = e
190
+ self.logger.error(
191
+ f"Unexpected error (attempt {attempt + 1})",
192
+ extra={"request_id": request_id, "error": str(e)},
193
+ )
194
+ if attempt < self.retries:
195
+ await asyncio.sleep(self.backoff_factor * (2 ** attempt))
196
+ continue
197
+
198
+ # All retries exhausted
199
+ span.set_status(trace.Status(trace.StatusCode.ERROR))
200
+ if last_exception:
201
+ raise ServiceUnavailableError(
202
+ f"Request failed after {self.retries + 1} attempts: {last_exception}",
203
+ request_id=request_id,
204
+ )
205
+ else:
206
+ raise ServiceUnavailableError(
207
+ f"Request failed after {self.retries + 1} attempts",
208
+ request_id=request_id,
209
+ )
210
+
211
+ async def _handle_retry_response(self, response: httpx.Response, attempt: int):
212
+ """Handle response that should be retried."""
213
+ if response.status_code == 429:
214
+ # Rate limited - check for Retry-After header
215
+ retry_after = response.headers.get("Retry-After")
216
+ if retry_after:
217
+ try:
218
+ delay = float(retry_after)
219
+ self.logger.info(f"Rate limited, retrying after {delay}s")
220
+ await asyncio.sleep(delay)
221
+ return
222
+ except ValueError:
223
+ pass
224
+
225
+ # Default exponential backoff
226
+ delay = self.backoff_factor * (2 ** attempt)
227
+ self.logger.info(f"Retrying after {delay}s")
228
+ await asyncio.sleep(delay)
229
+
230
+ async def _handle_error_response(self, response: httpx.Response, request_id: str):
231
+ """Handle error responses by raising appropriate exceptions."""
232
+ try:
233
+ error_data = response.json()
234
+ except Exception:
235
+ error_data = {
236
+ "error": "SERVER_ERROR",
237
+ "message": f"HTTP {response.status_code}",
238
+ "request_id": request_id,
239
+ }
240
+
241
+ # Map HTTP status codes to exceptions
242
+ status_code = response.status_code
243
+ if status_code == 401:
244
+ raise AuthenticationError(
245
+ error_data.get("message", "Authentication failed"),
246
+ request_id=request_id,
247
+ details=error_data.get("details", {}),
248
+ )
249
+ elif status_code == 403:
250
+ raise AuthorizationError(
251
+ error_data.get("message", "Access denied"),
252
+ request_id=request_id,
253
+ details=error_data.get("details", {}),
254
+ )
255
+ elif status_code == 404:
256
+ raise NotFoundError(
257
+ error_data.get("message", "Resource not found"),
258
+ request_id=request_id,
259
+ details=error_data.get("details", {}),
260
+ )
261
+ elif status_code == 400:
262
+ raise ValidationError(
263
+ error_data.get("message", "Validation failed"),
264
+ request_id=request_id,
265
+ details=error_data.get("details", {}),
266
+ )
267
+ elif status_code == 429:
268
+ raise RateLimitError(
269
+ error_data.get("message", "Rate limit exceeded"),
270
+ request_id=request_id,
271
+ details=error_data.get("details", {}),
272
+ )
273
+ elif status_code >= 500:
274
+ raise UpstreamError(
275
+ error_data.get("message", "Server error"),
276
+ request_id=request_id,
277
+ details=error_data.get("details", {}),
278
+ )
279
+ else:
280
+ # Use error response data if available
281
+ if "error" in error_data:
282
+ raise create_exception_from_response(error_data)
283
+ else:
284
+ raise UpstreamError(
285
+ f"HTTP {status_code}: {response.text}",
286
+ request_id=request_id,
287
+ )
288
+
289
+ async def get(
290
+ self,
291
+ path: str,
292
+ params: Optional[Dict[str, Any]] = None,
293
+ headers: Optional[Dict[str, str]] = None,
294
+ **kwargs,
295
+ ) -> httpx.Response:
296
+ """Make GET request."""
297
+ return await self._make_request("GET", path, params=params, headers=headers, **kwargs)
298
+
299
+ async def post(
300
+ self,
301
+ path: str,
302
+ json: Optional[Dict[str, Any]] = None,
303
+ data: Optional[Any] = None,
304
+ headers: Optional[Dict[str, str]] = None,
305
+ **kwargs,
306
+ ) -> httpx.Response:
307
+ """Make POST request."""
308
+ return await self._make_request("POST", path, json=json, data=data, headers=headers, **kwargs)
309
+
310
+ async def put(
311
+ self,
312
+ path: str,
313
+ json: Optional[Dict[str, Any]] = None,
314
+ data: Optional[Any] = None,
315
+ headers: Optional[Dict[str, str]] = None,
316
+ **kwargs,
317
+ ) -> httpx.Response:
318
+ """Make PUT request."""
319
+ return await self._make_request("PUT", path, json=json, data=data, headers=headers, **kwargs)
320
+
321
+ async def patch(
322
+ self,
323
+ path: str,
324
+ json: Optional[Dict[str, Any]] = None,
325
+ data: Optional[Any] = None,
326
+ headers: Optional[Dict[str, str]] = None,
327
+ **kwargs,
328
+ ) -> httpx.Response:
329
+ """Make PATCH request."""
330
+ return await self._make_request("PATCH", path, json=json, data=data, headers=headers, **kwargs)
331
+
332
+ async def delete(
333
+ self,
334
+ path: str,
335
+ headers: Optional[Dict[str, str]] = None,
336
+ **kwargs,
337
+ ) -> httpx.Response:
338
+ """Make DELETE request."""
339
+ return await self._make_request("DELETE", path, headers=headers, **kwargs)
340
+
341
+ def set_auth_token(self, token: str):
342
+ """Set authentication token."""
343
+ self.auth_token = token
344
+
345
+ def clear_auth_token(self):
346
+ """Clear authentication token."""
347
+ self.auth_token = None
348
+
349
+ async def stream(
350
+ self,
351
+ method: str,
352
+ path: str,
353
+ *,
354
+ headers: Optional[Dict[str, str]] = None,
355
+ params: Optional[Dict[str, Any]] = None,
356
+ json: Optional[Dict[str, Any]] = None,
357
+ ):
358
+ """Stream a response using httpx's streaming interface.
359
+
360
+ Returns an async context manager yielding the httpx.Response stream.
361
+ """
362
+ url = self._build_url(path)
363
+ request_headers = self._prepare_headers(headers)
364
+ # Add request id for traceability
365
+ request_headers.setdefault("X-Request-ID", f"req_{int(time.time() * 1000)}")
366
+ return self.client.stream(method, url, headers=request_headers, params=params, json=json)