feldera 0.131.0__py3-none-any.whl → 0.192.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,219 +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
- import time
16
-
17
-
18
- def json_serialize(body: Any) -> str:
19
- # serialize as string if this object cannot be serialized (e.g. UUID)
20
- return json.dumps(body, default=str) if body else "" if body == "" else "null"
21
-
22
-
23
- class HttpRequests:
24
- def __init__(self, config: Config) -> None:
25
- self.config = config
26
- self.headers = {"User-Agent": "feldera-python-sdk/v1"}
27
- self.requests_verify = config.requests_verify
28
-
29
- if not self.requests_verify:
30
- urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
31
-
32
- if self.config.api_key:
33
- self.headers["Authorization"] = f"Bearer {self.config.api_key}"
34
-
35
- def send_request(
36
- self,
37
- http_method: Callable,
38
- path: str,
39
- body: Optional[
40
- Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
41
- ] = None,
42
- content_type: str = "application/json",
43
- params: Optional[Mapping[str, Any]] = None,
44
- stream: bool = False,
45
- serialize: bool = True,
46
- max_retries: int = 3,
47
- ) -> Any:
48
- """
49
- :param http_method: The HTTP method to use. Takes the equivalent `requests.*` module. (Example: `requests.get`)
50
- :param path: The path to send the request to.
51
- :param body: The HTTP request body.
52
- :param content_type: The value for `Content-Type` HTTP header. "application/json" by default.
53
- :param params: The query parameters part of this request.
54
- :param stream: True if the response is expected to be a HTTP stream.
55
- :param serialize: True if the body needs to be serialized to JSON.
56
- """
57
- self.headers["Content-Type"] = content_type
58
-
59
- try:
60
- conn_timeout = self.config.connection_timeout
61
- timeout = self.config.timeout
62
- headers = self.headers
63
-
64
- request_path = self.config.url + "/" + self.config.version + path
65
-
66
- logging.debug(
67
- "sending %s request to: %s with headers: %s, and params: %s",
68
- http_method.__name__,
69
- request_path,
70
- str(headers),
71
- str(params),
72
- )
73
-
74
- for attempt in range(max_retries):
75
- if http_method.__name__ == "get":
76
- request = http_method(
77
- request_path,
78
- timeout=(conn_timeout, timeout),
79
- headers=headers,
80
- params=params,
81
- stream=stream,
82
- verify=self.requests_verify,
83
- )
84
- elif isinstance(body, bytes):
85
- request = http_method(
86
- request_path,
87
- timeout=(conn_timeout, timeout),
88
- headers=headers,
89
- data=body,
90
- params=params,
91
- stream=stream,
92
- verify=self.requests_verify,
93
- )
94
- else:
95
- request = http_method(
96
- request_path,
97
- timeout=(conn_timeout, timeout),
98
- headers=headers,
99
- data=json_serialize(body) if serialize else body,
100
- params=params,
101
- stream=stream,
102
- verify=self.requests_verify,
103
- )
104
-
105
- try:
106
- resp = self.__validate(request, stream=stream)
107
- logging.debug("got response: %s", str(resp))
108
- return resp
109
- except FelderaAPIError as err:
110
- # Only retry on 503
111
- if err.status_code == 503:
112
- if attempt < max_retries:
113
- logging.warning(
114
- "HTTP 503 received for %s, retrying (%d/%d)...",
115
- path,
116
- attempt + 1,
117
- max_retries,
118
- )
119
- time.sleep(2) # backoff, adjust as needed
120
- continue
121
- raise # re-raise for all other errors or if out of retries
122
- except requests.exceptions.Timeout as err:
123
- if attempt < max_retries:
124
- logging.warning(
125
- "HTTP Connection Timeout for %s, retrying (%d/%d)...",
126
- path,
127
- attempt + 1,
128
- max_retries,
129
- )
130
- time.sleep(2)
131
- continue
132
- raise FelderaTimeoutError(str(err)) from err
133
-
134
- except requests.exceptions.ConnectionError as err:
135
- raise FelderaCommunicationError(str(err)) from err
136
-
137
- def get(
138
- self,
139
- path: str,
140
- params: Optional[Mapping[str, Any]] = None,
141
- stream: bool = False,
142
- ) -> Any:
143
- return self.send_request(requests.get, path, params=params, stream=stream)
144
-
145
- def post(
146
- self,
147
- path: str,
148
- body: Optional[
149
- Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
150
- ] = None,
151
- content_type: Optional[str] = "application/json",
152
- params: Optional[Mapping[str, Any]] = None,
153
- stream: bool = False,
154
- serialize: bool = True,
155
- ) -> Any:
156
- return self.send_request(
157
- requests.post,
158
- path,
159
- body,
160
- content_type,
161
- params,
162
- stream=stream,
163
- serialize=serialize,
164
- )
165
-
166
- def patch(
167
- self,
168
- path: str,
169
- body: Optional[
170
- Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
171
- ] = None,
172
- content_type: Optional[str] = "application/json",
173
- params: Optional[Mapping[str, Any]] = None,
174
- ) -> Any:
175
- return self.send_request(requests.patch, path, body, content_type, params)
176
-
177
- def put(
178
- self,
179
- path: str,
180
- body: Optional[
181
- Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
182
- ] = None,
183
- content_type: Optional[str] = "application/json",
184
- params: Optional[Mapping[str, Any]] = None,
185
- ) -> Any:
186
- return self.send_request(requests.put, path, body, content_type, params)
187
-
188
- def delete(
189
- self,
190
- path: str,
191
- body: Optional[
192
- Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str]]
193
- ] = None,
194
- params: Optional[Mapping[str, Any]] = None,
195
- ) -> Any:
196
- return self.send_request(requests.delete, path, body, params=params)
197
-
198
- @staticmethod
199
- def __to_json(request: requests.Response) -> Any:
200
- if request.content == b"":
201
- return request
202
- return request.json()
203
-
204
- @staticmethod
205
- def __validate(request: requests.Response, stream=False) -> Any:
206
- try:
207
- request.raise_for_status()
208
-
209
- if stream:
210
- return request
211
- if request.headers.get("content-type") == "text/plain":
212
- return request.text
213
- elif request.headers.get("content-type") == "application/octet-stream":
214
- return request.content
215
-
216
- resp = HttpRequests.__to_json(request)
217
- return resp
218
- except requests.exceptions.HTTPError as err:
219
- 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