feldera 0.69.0__py3-none-any.whl → 0.189.0__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 feldera might be problematic. Click here for more details.

@@ -1,192 +1,365 @@
1
- import logging
2
-
3
- from feldera.rest.config import Config
4
-
5
- from feldera.rest.errors import (
6
- FelderaAPIError,
7
- FelderaTimeoutError,
8
- FelderaCommunicationError,
9
- )
10
-
11
- import json
12
- import requests
13
- from requests.packages import urllib3
14
- from typing import Callable, Optional, Any, Union, Mapping, Sequence, List
15
-
16
-
17
- def json_serialize(body: Any) -> str:
18
- # serialize as string if this object cannot be serialized (e.g. UUID)
19
- return json.dumps(body, default=str) if body else "" if body == "" else "null"
20
-
21
-
22
- class HttpRequests:
23
- def __init__(self, config: Config) -> None:
24
- self.config = config
25
- self.headers = {"User-Agent": "feldera-python-sdk/v1"}
26
- self.requests_verify = config.requests_verify
27
-
28
- if not self.requests_verify:
29
- urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
30
-
31
- if self.config.api_key:
32
- self.headers["Authorization"] = f"Bearer {self.config.api_key}"
33
-
34
- def send_request(
35
- self,
36
- http_method: Callable,
37
- path: str,
38
- body: Optional[
39
- Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
40
- ] = None,
41
- content_type: str = "application/json",
42
- params: Optional[Mapping[str, Any]] = None,
43
- stream: bool = False,
44
- serialize: bool = True,
45
- ) -> Any:
46
- """
47
- :param http_method: The HTTP method to use. Takes the equivalent `requests.*` module. (Example: `requests.get`)
48
- :param path: The path to send the request to.
49
- :param body: The HTTP request body.
50
- :param content_type: The value for `Content-Type` HTTP header. "application/json" by default.
51
- :param params: The query parameters part of this request.
52
- :param stream: True if the response is expected to be a HTTP stream.
53
- :param serialize: True if the body needs to be serialized to JSON.
54
- """
55
- self.headers["Content-Type"] = content_type
56
-
57
- try:
58
- timeout = self.config.timeout
59
- headers = self.headers
60
-
61
- request_path = self.config.url + "/" + self.config.version + path
62
-
63
- logging.debug(
64
- "sending %s request to: %s with headers: %s, and params: %s",
65
- http_method.__name__,
66
- request_path,
67
- str(headers),
68
- str(params),
69
- )
70
-
71
- if http_method.__name__ == "get":
72
- request = http_method(
73
- request_path,
74
- timeout=timeout,
75
- headers=headers,
76
- params=params,
77
- stream=stream,
78
- verify=self.requests_verify,
79
- )
80
- elif isinstance(body, bytes):
81
- request = http_method(
82
- request_path,
83
- timeout=timeout,
84
- headers=headers,
85
- data=body,
86
- params=params,
87
- stream=stream,
88
- verify=self.requests_verify,
89
- )
90
- else:
91
- request = http_method(
92
- request_path,
93
- timeout=timeout,
94
- headers=headers,
95
- data=json_serialize(body) if serialize else body,
96
- params=params,
97
- stream=stream,
98
- verify=self.requests_verify,
99
- )
100
-
101
- resp = self.__validate(request, stream=stream)
102
- logging.debug("got response: %s", str(resp))
103
- return resp
104
-
105
- except requests.exceptions.Timeout as err:
106
- raise FelderaTimeoutError(str(err)) from err
107
- except requests.exceptions.ConnectionError as err:
108
- raise FelderaCommunicationError(str(err)) from err
109
-
110
- def get(
111
- self,
112
- path: str,
113
- params: Optional[Mapping[str, Any]] = None,
114
- stream: bool = False,
115
- ) -> Any:
116
- return self.send_request(requests.get, path, params=params, stream=stream)
117
-
118
- def post(
119
- self,
120
- path: str,
121
- body: Optional[
122
- Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
123
- ] = None,
124
- content_type: Optional[str] = "application/json",
125
- params: Optional[Mapping[str, Any]] = None,
126
- stream: bool = False,
127
- serialize: bool = True,
128
- ) -> Any:
129
- return self.send_request(
130
- requests.post,
131
- path,
132
- body,
133
- content_type,
134
- params,
135
- stream=stream,
136
- serialize=serialize,
137
- )
138
-
139
- def patch(
140
- self,
141
- path: str,
142
- body: Optional[
143
- Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
144
- ] = None,
145
- content_type: Optional[str] = "application/json",
146
- params: Optional[Mapping[str, Any]] = None,
147
- ) -> Any:
148
- return self.send_request(requests.patch, path, body, content_type, params)
149
-
150
- def put(
151
- self,
152
- path: str,
153
- body: Optional[
154
- Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
155
- ] = None,
156
- content_type: Optional[str] = "application/json",
157
- params: Optional[Mapping[str, Any]] = None,
158
- ) -> Any:
159
- return self.send_request(requests.put, path, body, content_type, params)
160
-
161
- def delete(
162
- self,
163
- path: str,
164
- body: Optional[
165
- Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str]]
166
- ] = None,
167
- params: Optional[Mapping[str, Any]] = None,
168
- ) -> Any:
169
- return self.send_request(requests.delete, path, body, params=params)
170
-
171
- @staticmethod
172
- def __to_json(request: requests.Response) -> Any:
173
- if request.content == b"":
174
- return request
175
- return request.json()
176
-
177
- @staticmethod
178
- def __validate(request: requests.Response, stream=False) -> Any:
179
- try:
180
- request.raise_for_status()
181
-
182
- if stream:
183
- return request
184
- if request.headers.get("content-type") == "text/plain":
185
- return request.text
186
- elif request.headers.get("content-type") == "application/octet-stream":
187
- return request.content
188
-
189
- resp = HttpRequests.__to_json(request)
190
- return resp
191
- except requests.exceptions.HTTPError as err:
192
- raise FelderaAPIError(str(err), request) from err
1
+ import json
2
+ import logging
3
+ import time
4
+ from typing import Any, Callable, List, Mapping, Optional, Sequence, Union
5
+
6
+ import requests
7
+ from requests.packages import urllib3
8
+
9
+ from feldera.rest.config import Config
10
+ from feldera.rest.errors import (
11
+ FelderaAPIError,
12
+ FelderaCommunicationError,
13
+ FelderaTimeoutError,
14
+ )
15
+
16
+
17
+ def json_serialize(body: Any) -> str:
18
+ # serialize as string if this object cannot be serialized (e.g. UUID)
19
+ return json.dumps(body, default=str) if body else "" if body == "" else "null"
20
+
21
+
22
+ class HttpRequests:
23
+ def __init__(self, config: Config) -> None:
24
+ self.config = config
25
+ self.headers = {"User-Agent": "feldera-python-sdk/v1"}
26
+ self.requests_verify = config.requests_verify
27
+
28
+ if isinstance(self.requests_verify, bool) and not self.requests_verify:
29
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
30
+
31
+ if self.config.api_key:
32
+ self.headers["Authorization"] = f"Bearer {self.config.api_key}"
33
+
34
+ def _check_cluster_health(self) -> bool:
35
+ """Check cluster health via /cluster_healthz endpoint.
36
+
37
+ Returns:
38
+ bool: True if both runner and compiler services are healthy, False otherwise
39
+ """
40
+ try:
41
+ health_path = (
42
+ self.config.url + "/" + self.config.version + "/cluster_healthz"
43
+ )
44
+ response = requests.get(
45
+ health_path,
46
+ timeout=(self.config.connection_timeout, self.config.timeout),
47
+ headers=self.headers,
48
+ verify=self.requests_verify,
49
+ )
50
+
51
+ if response.status_code == 200:
52
+ health_data = response.json()
53
+ runner_healthy = health_data.get("runner", {}).get("healthy", False)
54
+ compiler_healthy = health_data.get("compiler", {}).get("healthy", False)
55
+ return runner_healthy and compiler_healthy
56
+ else:
57
+ logging.warning(
58
+ f"Health check returned status {response.status_code}. The instance might be in the process of being upgraded. Waiting to see if it recovers."
59
+ )
60
+ return False
61
+ except Exception as e:
62
+ logging.error(f"Health check failed: {e}")
63
+ return False
64
+
65
+ def _wait_for_health_recovery(self, max_wait_seconds: int = 300) -> bool:
66
+ """
67
+ Wait for cluster to become healthy after detecting upgrade/restart.
68
+
69
+ Args:
70
+ max_wait_seconds: Maximum time to wait for health recovery (default 5 minutes)
71
+
72
+ Returns:
73
+ bool: True if cluster became healthy within timeout, False otherwise
74
+ """
75
+ start_time = time.time()
76
+ check_interval = 5
77
+
78
+ logging.info(
79
+ f"Waiting for cluster health recovery (max {max_wait_seconds}s)..."
80
+ )
81
+
82
+ while time.time() - start_time < max_wait_seconds:
83
+ if self._check_cluster_health():
84
+ elapsed = time.time() - start_time
85
+ logging.info(f"Instance health recovered after {elapsed:.1f}s")
86
+ return True
87
+
88
+ time.sleep(check_interval)
89
+ elapsed = time.time() - start_time
90
+ logging.debug(
91
+ f"Still waiting for health recovery ({elapsed:.1f}s elapsed)..."
92
+ )
93
+
94
+ logging.warning(f"Instance did not recover within {max_wait_seconds}s timeout")
95
+ return False
96
+
97
+ def _handle_502_with_health_check(
98
+ self, path: str, attempt: int, max_retries: int
99
+ ) -> bool:
100
+ """
101
+ Handles 502 errors with health monitoring.
102
+
103
+ Args:
104
+ path: The request path that failed
105
+ attempt: Current attempt number (0-based)
106
+ max_retries: Maximum number of retries allowed
107
+
108
+ Returns:
109
+ bool: True if should retry, False if should give up
110
+ """
111
+ if attempt >= max_retries:
112
+ return False
113
+
114
+ logging.warning(
115
+ f"HTTP 502 received for {path} (attempt {attempt + 1}/{max_retries + 1}), checking cluster health..."
116
+ )
117
+
118
+ # Do a short backoff and retry
119
+ time.sleep(min(2 << attempt, 64))
120
+
121
+ # First, check if cluster is currently healthy
122
+ if self._check_cluster_health():
123
+ # Instance appears healthy, this might be a spurious 502
124
+ logging.info(
125
+ f"Instance appears healthy, treating 502 for {path} as spurious - retrying"
126
+ )
127
+ return True
128
+ else:
129
+ # Instance is unhealthy, likely an upgrade/restart scenario
130
+ logging.info(
131
+ f"Instance unhealthy for request to {path}, likely upgrade in progress - waiting for recovery..."
132
+ )
133
+
134
+ # Wait for cluster to recover (up to 5 minutes)
135
+ recovery_timeout = self.config.health_recovery_timeout
136
+ if self._wait_for_health_recovery(max_wait_seconds=recovery_timeout):
137
+ logging.info(f"Instance health recovered, can now retry {path}...")
138
+ return True
139
+ else:
140
+ # Health didn't recover within timeout
141
+ logging.error(
142
+ f"Instance health did not recover within {recovery_timeout}s timeout for {path}, giving up"
143
+ )
144
+ return False
145
+
146
+ def send_request(
147
+ self,
148
+ http_method: Callable,
149
+ path: str,
150
+ body: Optional[
151
+ Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
152
+ ] = None,
153
+ content_type: str = "application/json",
154
+ params: Optional[Mapping[str, Any]] = None,
155
+ stream: bool = False,
156
+ serialize: bool = True,
157
+ max_retries: int = 3,
158
+ ) -> Any:
159
+ """
160
+ Send HTTP request with intelligent retry logic.
161
+
162
+ Handles different error conditions with appropriate retry strategies:
163
+ - 502 errors: Checks cluster health and waits for recovery during upgrades
164
+ - 503/408 errors: Standard retry with backoff
165
+ - Timeout errors: Standard retry with backoff
166
+
167
+ For 502 errors, the method distinguishes between:
168
+ 1. Spurious 502s (cluster healthy): Quick retry with short backoff
169
+ 2. Upgrade scenarios (cluster unhealthy): Waits up to health_recovery_timeout
170
+ for cluster to become healthy, then retries
171
+
172
+ :param http_method: The HTTP method to use. Takes the equivalent `requests.*` module. (Example: `requests.get`)
173
+ :param path: The path to send the request to.
174
+ :param body: The HTTP request body.
175
+ :param content_type: The value for `Content-Type` HTTP header. "application/json" by default.
176
+ :param params: The query parameters part of this request.
177
+ :param stream: True if the response is expected to be a HTTP stream.
178
+ :param serialize: True if the body needs to be serialized to JSON.
179
+ :param max_retries: Maximum number of retry attempts.
180
+ """
181
+ self.headers["Content-Type"] = content_type
182
+
183
+ prev_resp: requests.Response | None = None
184
+
185
+ try:
186
+ conn_timeout = self.config.connection_timeout
187
+ timeout = self.config.timeout
188
+ headers = self.headers
189
+
190
+ request_path = self.config.url + "/" + self.config.version + path
191
+
192
+ logging.debug(
193
+ "sending %s request to: %s with headers: %s, and params: %s",
194
+ http_method.__name__,
195
+ request_path,
196
+ str(headers),
197
+ str(params),
198
+ )
199
+
200
+ for attempt in range(max_retries):
201
+ if http_method.__name__ == "get":
202
+ request = http_method(
203
+ request_path,
204
+ timeout=(conn_timeout, timeout),
205
+ headers=headers,
206
+ params=params,
207
+ stream=stream,
208
+ verify=self.requests_verify,
209
+ )
210
+ elif isinstance(body, bytes):
211
+ request = http_method(
212
+ request_path,
213
+ timeout=(conn_timeout, timeout),
214
+ headers=headers,
215
+ data=body,
216
+ params=params,
217
+ stream=stream,
218
+ verify=self.requests_verify,
219
+ )
220
+ else:
221
+ request = http_method(
222
+ request_path,
223
+ timeout=(conn_timeout, timeout),
224
+ headers=headers,
225
+ data=json_serialize(body) if serialize else body,
226
+ params=params,
227
+ stream=stream,
228
+ verify=self.requests_verify,
229
+ )
230
+
231
+ prev_resp = request
232
+
233
+ try:
234
+ resp = self.__validate(request, stream=stream)
235
+ logging.debug("got response: %s", str(resp))
236
+ return resp
237
+ except FelderaAPIError as err:
238
+ # Handle 502 with intelligent health monitoring
239
+ if err.status_code == 502:
240
+ if self._handle_502_with_health_check(
241
+ path, attempt, max_retries
242
+ ):
243
+ continue
244
+ else:
245
+ raise
246
+ # Handle other retryable errors
247
+ elif err.status_code in [408, 503, 504]:
248
+ if attempt < max_retries:
249
+ logging.warning(
250
+ "HTTP %d received for %s, retrying (%d/%d)...",
251
+ err.status_code,
252
+ path,
253
+ attempt + 1,
254
+ max_retries,
255
+ )
256
+ time.sleep(min(2 << attempt, 64))
257
+ continue
258
+ raise # re-raise for all other errors or if out of retries
259
+ except requests.exceptions.Timeout as err:
260
+ if attempt < max_retries:
261
+ logging.warning(
262
+ "HTTP Connection Timeout for %s, retrying (%d/%d)...",
263
+ path,
264
+ attempt + 1,
265
+ max_retries,
266
+ )
267
+ time.sleep(2)
268
+ continue
269
+ raise FelderaTimeoutError(str(err)) from err
270
+
271
+ except requests.exceptions.ConnectionError as err:
272
+ raise FelderaCommunicationError(str(err)) from err
273
+
274
+ raise FelderaAPIError(
275
+ "Max retries exceeded, couldn't successfully connect to Feldera", prev_resp
276
+ )
277
+
278
+ def get(
279
+ self,
280
+ path: str,
281
+ params: Optional[Mapping[str, Any]] = None,
282
+ stream: bool = False,
283
+ ) -> Any:
284
+ return self.send_request(requests.get, path, params=params, stream=stream)
285
+
286
+ def post(
287
+ self,
288
+ path: str,
289
+ body: Optional[
290
+ Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
291
+ ] = None,
292
+ content_type: str = "application/json",
293
+ params: Optional[Mapping[str, Any]] = None,
294
+ stream: bool = False,
295
+ serialize: bool = True,
296
+ ) -> Any:
297
+ return self.send_request(
298
+ requests.post,
299
+ path,
300
+ body,
301
+ content_type,
302
+ params,
303
+ stream=stream,
304
+ serialize=serialize,
305
+ )
306
+
307
+ def patch(
308
+ self,
309
+ path: str,
310
+ body: Optional[
311
+ Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
312
+ ] = None,
313
+ content_type: str = "application/json",
314
+ params: Optional[Mapping[str, Any]] = None,
315
+ ) -> Any:
316
+ return self.send_request(requests.patch, path, body, content_type, params)
317
+
318
+ def put(
319
+ self,
320
+ path: str,
321
+ body: Optional[
322
+ Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
323
+ ] = None,
324
+ content_type: str = "application/json",
325
+ params: Optional[Mapping[str, Any]] = None,
326
+ ) -> Any:
327
+ return self.send_request(requests.put, path, body, content_type, params)
328
+
329
+ def delete(
330
+ self,
331
+ path: str,
332
+ body: Optional[
333
+ Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str]]
334
+ ] = None,
335
+ params: Optional[Mapping[str, Any]] = None,
336
+ ) -> Any:
337
+ return self.send_request(requests.delete, path, body, params=params)
338
+
339
+ @staticmethod
340
+ def __to_json(request: requests.Response) -> Any:
341
+ if request.content == b"":
342
+ return request
343
+ return request.json()
344
+
345
+ @staticmethod
346
+ def __validate(request: requests.Response, stream=False) -> Any:
347
+ try:
348
+ request.raise_for_status()
349
+
350
+ if request is None:
351
+ # This shouldn't ever be the case, but we've seen it happen
352
+ return FelderaCommunicationError(
353
+ "Failed to Communicate with Feldera Received None as Response",
354
+ )
355
+ if stream:
356
+ return request
357
+ if request.headers.get("content-type") == "text/plain":
358
+ return request.text
359
+ elif request.headers.get("content-type") == "application/octet-stream":
360
+ return request.content
361
+
362
+ resp = HttpRequests.__to_json(request)
363
+ return resp
364
+ except requests.exceptions.HTTPError as err:
365
+ raise FelderaAPIError(str(err), request) from err
feldera/rest/config.py CHANGED
@@ -1,30 +1,44 @@
1
- from typing import Optional
2
-
3
-
4
- class Config:
5
- """
6
- :class:`.FelderaClient`'s credentials and configuration parameters
7
- """
8
-
9
- def __init__(
10
- self,
11
- url: str,
12
- api_key: Optional[str] = None,
13
- version: Optional[str] = None,
14
- timeout: Optional[float] = None,
15
- requests_verify: bool = True,
16
- ) -> None:
17
- """
18
- :param url: The url to the Feldera API (ex: https://try.feldera.com)
19
- :param api_key: The optional API key to access Feldera
20
- :param version: The version of the API to use
21
- :param timeout: The timeout for the HTTP requests
22
- :param requests_verify: The `verify` parameter passed to the requests
23
- library. `True` by default.
24
- """
25
-
26
- self.url: str = url
27
- self.api_key: Optional[str] = api_key
28
- self.version: Optional[str] = version or "v0"
29
- self.timeout: Optional[float] = timeout
30
- self.requests_verify: bool = requests_verify
1
+ import logging
2
+ import os
3
+ from typing import Optional
4
+
5
+ from feldera.rest._helpers import requests_verify_from_env
6
+
7
+
8
+ class Config:
9
+ """
10
+ :class:`.FelderaClient` configuration, which includes authentication information
11
+ and the address of the Feldera API the client will interact with.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ url: Optional[str] = None,
17
+ api_key: Optional[str] = None,
18
+ version: Optional[str] = None,
19
+ timeout: Optional[float] = None,
20
+ connection_timeout: Optional[float] = None,
21
+ requests_verify: Optional[bool | str] = None,
22
+ health_recovery_timeout: Optional[int] = None,
23
+ ) -> None:
24
+ """
25
+ See documentation of the `FelderaClient` constructor for the other arguments.
26
+
27
+ :param version: (Optional) Version of the API to use.
28
+ Default: `v0`.
29
+ :param health_recovery_timeout: (Optional) Maximum time in seconds to wait for cluster health recovery after a 502 error.
30
+ Default: `300` (5 minutes).
31
+ """
32
+ self.url: str = url or os.environ.get("FELDERA_HOST") or "http://localhost:8080"
33
+ self.api_key: Optional[str] = api_key or os.environ.get("FELDERA_API_KEY")
34
+ self.version: str = version or "v0"
35
+ self.timeout: Optional[float] = timeout
36
+ self.connection_timeout: Optional[float] = connection_timeout
37
+ self.health_recovery_timeout: int = health_recovery_timeout or 300
38
+ env_verify = requests_verify_from_env()
39
+ self.requests_verify: bool | str = (
40
+ requests_verify if requests_verify is not None else env_verify
41
+ )
42
+
43
+ if self.requests_verify is False:
44
+ logging.warning("Feldera client: TLS verification is disabled!")
feldera/rest/errors.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from requests import Response
2
2
  import json
3
+ from urllib.parse import urlparse
3
4
 
4
5
 
5
6
  class FelderaError(Exception):
@@ -40,6 +41,21 @@ class FelderaAPIError(FelderaError):
40
41
  self.details = json_data.get("details")
41
42
  except Exception:
42
43
  self.message = request.text
44
+ err_msg += request.text
45
+
46
+ err_msg += f"\nResponse Status: {request.status_code}"
47
+
48
+ if int(request.status_code) == 401:
49
+ parsed = urlparse(request.request.url)
50
+
51
+ auth_err = f"\nAuthorization error: Failed to connect to '{parsed.scheme}://{parsed.hostname}': "
52
+ auth = request.request.headers.get("Authorization")
53
+ if auth is None:
54
+ err_msg += f"{auth_err} API key not set"
55
+ else:
56
+ err_msg += f"{auth_err} invalid API key"
57
+
58
+ err_msg = err_msg.strip()
43
59
 
44
60
  super().__init__(err_msg)
45
61