sfq 0.0.37__tar.gz → 0.0.39__tar.gz
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.
- {sfq-0.0.37 → sfq-0.0.39}/.github/workflows/publish.yml +1 -2
- {sfq-0.0.37 → sfq-0.0.39}/PKG-INFO +1 -1
- {sfq-0.0.37 → sfq-0.0.39}/pyproject.toml +1 -1
- {sfq-0.0.37 → sfq-0.0.39}/src/sfq/__init__.py +7 -4
- {sfq-0.0.37 → sfq-0.0.39}/src/sfq/exceptions.py +6 -0
- {sfq-0.0.37 → sfq-0.0.39}/src/sfq/http_client.py +149 -6
- sfq-0.0.39/src/sfq/timeout_detector.py +147 -0
- {sfq-0.0.37 → sfq-0.0.39}/src/sfq/utils.py +66 -11
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_compatibility.py +2 -0
- sfq-0.0.39/tests/test_http_client_retry.py +977 -0
- sfq-0.0.39/tests/test_query_client_timeout_integration.py +598 -0
- sfq-0.0.39/tests/test_records_to_html.py +153 -0
- sfq-0.0.39/tests/test_timeout_detector.py +357 -0
- sfq-0.0.39/tests/test_timeout_edge_cases.py +887 -0
- sfq-0.0.39/tests/test_timeout_scenarios_comprehensive.py +874 -0
- sfq-0.0.39/tests/test_timeout_scenarios_summary.py +355 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_utils.py +25 -0
- {sfq-0.0.37 → sfq-0.0.39}/uv.lock +1 -1
- sfq-0.0.37/tests/test_records_to_html.py +0 -45
- {sfq-0.0.37 → sfq-0.0.39}/.gitignore +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/.python-version +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/README.md +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/src/sfq/_cometd.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/src/sfq/auth.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/src/sfq/crud.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/src/sfq/debug_cleanup.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/src/sfq/py.typed +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/src/sfq/query.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/src/sfq/soap.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_complex_nested.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_complex_nested_styled.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_empty_list.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_empty_list_styled.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_int_float_bool.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_int_float_bool_styled.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_list_value.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_list_value_styled.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_multiple_dicts.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_multiple_dicts_styled.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_nested_dict.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_nested_dict_styled.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_none_value.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_none_value_styled.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_other_types.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_other_types_styled.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_sample_report.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_sample_report_styled.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_single_flat_dict.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_single_flat_dict_styled.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_typecastable_keys_bool.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_typecastable_keys_bool_styled.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_typecastable_keys_float.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_typecastable_keys_float_styled.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_typecastable_keys_int.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/html/test_typecastable_keys_int_styled.html +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_auth.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_cdelete.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_cquery.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_create.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_crud.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_crud_e2e.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_cupdate.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_debug_cleanup_e2e.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_debug_cleanup_unit.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_http_client.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_limits_api.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_log_trace_redact.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_open_frontdoor.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_query.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_query_client.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_query_e2e.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_query_integration.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_soap.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_soap_batch_operation.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_static_resources.py +0 -0
- {sfq-0.0.37 → sfq-0.0.39}/tests/test_utils_html_table.py +0 -0
@@ -76,7 +76,6 @@ jobs:
|
|
76
76
|
|
77
77
|
echo "Updating src/sfq/__init__.py user_agent to $VERSION"
|
78
78
|
sed -i -E "s/(user_agent: str = \"sfq\/)[0-9]+\.[0-9]+\.[0-9]+(\")/\1$VERSION\2/" src/sfq/__init__.py
|
79
|
-
sed -i -E "s/(user_agent: str = \"sfq\/)[0-9]+\.[0-9]+\.[0-9]+(\")/\1$VERSION\2/" src/sfq/http_client.py
|
80
79
|
sed -i -E "s/(default is \"sfq\/)[0-9]+\.[0-9]+\.[0-9]+(\")/\1$VERSION\2/" src/sfq/__init__.py
|
81
80
|
|
82
81
|
echo "Updating src/sfq/__init__.py __version__ to $VERSION"
|
@@ -100,7 +99,7 @@ jobs:
|
|
100
99
|
run: |
|
101
100
|
git config user.name "github-actions[bot]"
|
102
101
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
103
|
-
git add pyproject.toml uv.lock src/sfq/__init__.py
|
102
|
+
git add pyproject.toml uv.lock src/sfq/__init__.py src/sfq/http_client.py
|
104
103
|
git commit -m "CI: bump version to ${{ steps.get_version.outputs.version }}"
|
105
104
|
git push
|
106
105
|
|
@@ -18,6 +18,7 @@ from .exceptions import (
|
|
18
18
|
CRUDError,
|
19
19
|
HTTPError,
|
20
20
|
QueryError,
|
21
|
+
QueryTimeoutError,
|
21
22
|
SFQException,
|
22
23
|
SOAPError,
|
23
24
|
)
|
@@ -35,6 +36,7 @@ __all__ = [
|
|
35
36
|
"AuthenticationError",
|
36
37
|
"APIError",
|
37
38
|
"QueryError",
|
39
|
+
"QueryTimeoutError",
|
38
40
|
"CRUDError",
|
39
41
|
"SOAPError",
|
40
42
|
"HTTPError",
|
@@ -43,7 +45,7 @@ __all__ = [
|
|
43
45
|
"__version__",
|
44
46
|
]
|
45
47
|
|
46
|
-
__version__ = "0.0.
|
48
|
+
__version__ = "0.0.39"
|
47
49
|
"""
|
48
50
|
### `__version__`
|
49
51
|
|
@@ -67,7 +69,7 @@ class SFAuth:
|
|
67
69
|
access_token: Optional[str] = None,
|
68
70
|
token_expiration_time: Optional[float] = None,
|
69
71
|
token_lifetime: int = 15 * 60,
|
70
|
-
user_agent: str = "sfq/0.0.
|
72
|
+
user_agent: str = "sfq/0.0.39",
|
71
73
|
sforce_client: str = "_auto",
|
72
74
|
proxy: str = "_auto",
|
73
75
|
) -> None:
|
@@ -132,7 +134,7 @@ class SFAuth:
|
|
132
134
|
self._debug_cleanup = DebugCleanup(sf_auth=self)
|
133
135
|
|
134
136
|
# Store version information
|
135
|
-
self.__version__ = "0.0.
|
137
|
+
self.__version__ = "0.0.39"
|
136
138
|
"""
|
137
139
|
### `__version__`
|
138
140
|
|
@@ -565,6 +567,7 @@ class SFAuth:
|
|
565
567
|
def records_to_html_table(
|
566
568
|
self,
|
567
569
|
items: List[Dict[str, Any]],
|
570
|
+
headers: Dict[str, str] = None,
|
568
571
|
styled: bool = False,
|
569
572
|
) -> str:
|
570
573
|
"""
|
@@ -576,4 +579,4 @@ class SFAuth:
|
|
576
579
|
"""
|
577
580
|
if "records" in items:
|
578
581
|
items = items["records"]
|
579
|
-
return records_to_html_table(items, styled=styled)
|
582
|
+
return records_to_html_table(items, headers=headers, styled=styled)
|
@@ -10,7 +10,8 @@ import json
|
|
10
10
|
from typing import Dict, Optional, Tuple
|
11
11
|
|
12
12
|
from .auth import AuthManager
|
13
|
-
from .exceptions import ConfigurationError
|
13
|
+
from .exceptions import ConfigurationError, QueryTimeoutError
|
14
|
+
from .timeout_detector import TimeoutDetector
|
14
15
|
from .utils import format_headers_for_logging, get_logger, log_api_usage
|
15
16
|
|
16
17
|
logger = get_logger(__name__)
|
@@ -28,7 +29,7 @@ class HTTPClient:
|
|
28
29
|
def __init__(
|
29
30
|
self,
|
30
31
|
auth_manager: AuthManager,
|
31
|
-
user_agent: str = "sfq/0.0.
|
32
|
+
user_agent: str = "sfq/0.0.39",
|
32
33
|
sforce_client: str = "_auto",
|
33
34
|
high_api_usage_threshold: int = 80,
|
34
35
|
) -> None:
|
@@ -184,18 +185,134 @@ class HTTPClient:
|
|
184
185
|
if key == "Sforce-Limit-Info":
|
185
186
|
log_api_usage(value, self.high_api_usage_threshold)
|
186
187
|
|
187
|
-
def
|
188
|
+
def send_authenticated_request_with_retry(
|
188
189
|
self,
|
189
190
|
method: str,
|
190
191
|
endpoint: str,
|
191
192
|
body: Optional[str] = None,
|
192
193
|
additional_headers: Optional[Dict[str, str]] = None,
|
194
|
+
max_retries: int = 3,
|
193
195
|
) -> Tuple[Optional[int], Optional[str]]:
|
194
196
|
"""
|
195
|
-
Send an authenticated HTTP request with automatic
|
197
|
+
Send an authenticated HTTP request with automatic timeout retry.
|
196
198
|
|
197
|
-
This
|
198
|
-
|
199
|
+
This method wraps the existing send_authenticated_request with retry logic
|
200
|
+
that handles timeout errors by retrying up to max_retries times.
|
201
|
+
|
202
|
+
:param method: HTTP method
|
203
|
+
:param endpoint: API endpoint path
|
204
|
+
:param body: Optional request body
|
205
|
+
:param additional_headers: Optional additional headers to include
|
206
|
+
:param max_retries: Maximum number of retry attempts (default: 3)
|
207
|
+
:return: Tuple of (status_code, response_body) or (None, None) on failure
|
208
|
+
:raises QueryTimeoutError: When all retry attempts fail with timeout errors
|
209
|
+
"""
|
210
|
+
# Validate retry count - negative values should be treated as 0
|
211
|
+
if max_retries < 0:
|
212
|
+
max_retries = 0
|
213
|
+
|
214
|
+
last_exception = None
|
215
|
+
request_context = f"{method} {endpoint}"
|
216
|
+
|
217
|
+
# Log initial request context for debugging
|
218
|
+
logger.trace("Starting request with retry capability: %s (max_retries=%d)",
|
219
|
+
request_context, max_retries)
|
220
|
+
|
221
|
+
for attempt in range(max_retries + 1): # +1 for initial attempt
|
222
|
+
try:
|
223
|
+
# Make the request using the original method
|
224
|
+
status, response_body = self._send_authenticated_request_internal(
|
225
|
+
method, endpoint, body, additional_headers
|
226
|
+
)
|
227
|
+
|
228
|
+
# Check if this is a timeout error
|
229
|
+
if TimeoutDetector.is_timeout_error(status, response_body, last_exception):
|
230
|
+
timeout_type = TimeoutDetector.get_timeout_type(status, response_body, last_exception)
|
231
|
+
|
232
|
+
if attempt < max_retries:
|
233
|
+
# Log detailed retry initiation with timeout type identification
|
234
|
+
logger.debug(
|
235
|
+
"Timeout detected (%s timeout) on attempt %d/%d for %s - "
|
236
|
+
"status_code=%s, retrying...",
|
237
|
+
timeout_type, attempt + 1, max_retries + 1, request_context, status
|
238
|
+
)
|
239
|
+
continue
|
240
|
+
else:
|
241
|
+
# All retries exhausted - log error before raising exception
|
242
|
+
logger.error(
|
243
|
+
"All %d retry attempts failed with timeout errors for %s - "
|
244
|
+
"final timeout type: %s, final status_code: %s",
|
245
|
+
max_retries + 1, request_context, timeout_type, status
|
246
|
+
)
|
247
|
+
raise QueryTimeoutError("QUERY_TIMEOUT")
|
248
|
+
|
249
|
+
# Not a timeout error or successful response, return immediately
|
250
|
+
if attempt > 0:
|
251
|
+
# Log successful retry recovery
|
252
|
+
logger.debug(
|
253
|
+
"Request succeeded on retry attempt %d/%d for %s - "
|
254
|
+
"status_code=%s, recovered from timeout",
|
255
|
+
attempt + 1, max_retries + 1, request_context, status
|
256
|
+
)
|
257
|
+
else:
|
258
|
+
# Log successful first attempt (trace level to avoid noise)
|
259
|
+
logger.trace("Request succeeded on first attempt for %s - status_code=%s",
|
260
|
+
request_context, status)
|
261
|
+
|
262
|
+
return status, response_body
|
263
|
+
|
264
|
+
except QueryTimeoutError:
|
265
|
+
# Re-raise QueryTimeoutError without logging (it was already logged)
|
266
|
+
raise
|
267
|
+
except Exception as e:
|
268
|
+
last_exception = e
|
269
|
+
|
270
|
+
# Check if this exception indicates a timeout
|
271
|
+
if TimeoutDetector.is_timeout_error(None, None, e):
|
272
|
+
timeout_type = TimeoutDetector.get_timeout_type(None, None, e)
|
273
|
+
|
274
|
+
if attempt < max_retries:
|
275
|
+
# Log detailed retry initiation with exception context
|
276
|
+
logger.debug(
|
277
|
+
"Timeout exception (%s timeout) on attempt %d/%d for %s - "
|
278
|
+
"exception: %s, retrying...",
|
279
|
+
timeout_type, attempt + 1, max_retries + 1, request_context,
|
280
|
+
type(e).__name__
|
281
|
+
)
|
282
|
+
continue
|
283
|
+
else:
|
284
|
+
# All retries exhausted - log error with exception details before raising
|
285
|
+
logger.error(
|
286
|
+
"All %d retry attempts failed with timeout errors for %s - "
|
287
|
+
"final timeout type: %s, final exception: %s",
|
288
|
+
max_retries + 1, request_context, timeout_type, type(e).__name__
|
289
|
+
)
|
290
|
+
raise QueryTimeoutError("QUERY_TIMEOUT")
|
291
|
+
else:
|
292
|
+
# Not a timeout exception - log and re-raise immediately
|
293
|
+
logger.debug(
|
294
|
+
"Non-timeout exception on attempt %d for %s - "
|
295
|
+
"exception: %s, not retrying",
|
296
|
+
attempt + 1, request_context, type(e).__name__
|
297
|
+
)
|
298
|
+
raise e
|
299
|
+
|
300
|
+
# This should never be reached, but just in case
|
301
|
+
logger.error("Unexpected code path reached in retry logic for %s", request_context)
|
302
|
+
raise QueryTimeoutError("QUERY_TIMEOUT")
|
303
|
+
|
304
|
+
def _send_authenticated_request_internal(
|
305
|
+
self,
|
306
|
+
method: str,
|
307
|
+
endpoint: str,
|
308
|
+
body: Optional[str] = None,
|
309
|
+
additional_headers: Optional[Dict[str, str]] = None,
|
310
|
+
) -> Tuple[Optional[int], Optional[str]]:
|
311
|
+
"""
|
312
|
+
Internal method for sending authenticated requests without retry logic.
|
313
|
+
|
314
|
+
This is the original send_authenticated_request logic extracted to avoid
|
315
|
+
recursion in the retry wrapper.
|
199
316
|
|
200
317
|
:param method: HTTP method
|
201
318
|
:param endpoint: API endpoint path
|
@@ -210,6 +327,32 @@ class HTTPClient:
|
|
210
327
|
|
211
328
|
return self.send_request(method, endpoint, headers, body)
|
212
329
|
|
330
|
+
def send_authenticated_request(
|
331
|
+
self,
|
332
|
+
method: str,
|
333
|
+
endpoint: str,
|
334
|
+
body: Optional[str] = None,
|
335
|
+
additional_headers: Optional[Dict[str, str]] = None,
|
336
|
+
max_retries: int = 3,
|
337
|
+
) -> Tuple[Optional[int], Optional[str]]:
|
338
|
+
"""
|
339
|
+
Send an authenticated HTTP request with automatic timeout retry.
|
340
|
+
|
341
|
+
This is a convenience method that handles common request patterns
|
342
|
+
with authentication, standard headers, and automatic retry for timeout errors.
|
343
|
+
|
344
|
+
:param method: HTTP method
|
345
|
+
:param endpoint: API endpoint path
|
346
|
+
:param body: Optional request body
|
347
|
+
:param additional_headers: Optional additional headers to include
|
348
|
+
:param max_retries: Maximum number of retry attempts (default: 3)
|
349
|
+
:return: Tuple of (status_code, response_body) or (None, None) on failure
|
350
|
+
:raises QueryTimeoutError: When all retry attempts fail with timeout errors
|
351
|
+
"""
|
352
|
+
return self.send_authenticated_request_with_retry(
|
353
|
+
method, endpoint, body, additional_headers, max_retries
|
354
|
+
)
|
355
|
+
|
213
356
|
def send_token_request(
|
214
357
|
self,
|
215
358
|
payload: Dict[str, str],
|
@@ -0,0 +1,147 @@
|
|
1
|
+
"""
|
2
|
+
Timeout detection module for the SFQ library.
|
3
|
+
|
4
|
+
This module provides utilities for detecting different types of timeout errors
|
5
|
+
that can occur during HTTP requests to Salesforce APIs.
|
6
|
+
"""
|
7
|
+
|
8
|
+
from typing import Optional
|
9
|
+
import errno
|
10
|
+
|
11
|
+
|
12
|
+
class TimeoutDetector:
|
13
|
+
"""Utility class for detecting timeout conditions in HTTP responses."""
|
14
|
+
|
15
|
+
# Server timeout message pattern
|
16
|
+
SERVER_TIMEOUT_MESSAGE = "Your query request was running for too long."
|
17
|
+
# Server timeout error code pattern
|
18
|
+
SERVER_TIMEOUT_ERROR_CODE = "QUERY_TIMEOUT"
|
19
|
+
|
20
|
+
@staticmethod
|
21
|
+
def is_server_timeout(status_code: Optional[int], response_body: Optional[str]) -> bool:
|
22
|
+
"""
|
23
|
+
Detect server-side query timeout from HTTP response.
|
24
|
+
|
25
|
+
Server timeouts are indicated by:
|
26
|
+
- HTTP status code 400
|
27
|
+
- Response body containing the specific timeout message OR timeout error code
|
28
|
+
|
29
|
+
Args:
|
30
|
+
status_code: HTTP status code from the response
|
31
|
+
response_body: Response body content as string
|
32
|
+
|
33
|
+
Returns:
|
34
|
+
bool: True if this is a server timeout, False otherwise
|
35
|
+
"""
|
36
|
+
if status_code != 400:
|
37
|
+
return False
|
38
|
+
|
39
|
+
if response_body is None or response_body == "":
|
40
|
+
return False
|
41
|
+
|
42
|
+
# Check for either the timeout message or the error code
|
43
|
+
# Handle potential encoding issues by using string containment check
|
44
|
+
try:
|
45
|
+
return (TimeoutDetector.SERVER_TIMEOUT_MESSAGE in response_body or
|
46
|
+
TimeoutDetector.SERVER_TIMEOUT_ERROR_CODE in response_body)
|
47
|
+
except (TypeError, UnicodeError):
|
48
|
+
# If there are encoding issues, assume it's not a timeout
|
49
|
+
return False
|
50
|
+
|
51
|
+
@staticmethod
|
52
|
+
def is_connection_timeout(
|
53
|
+
status_code: Optional[int],
|
54
|
+
response_body: Optional[str],
|
55
|
+
exception: Optional[Exception] = None
|
56
|
+
) -> bool:
|
57
|
+
"""
|
58
|
+
Detect connection timeout from HTTP response and exception context.
|
59
|
+
|
60
|
+
Connection timeouts are indicated by:
|
61
|
+
- HTTP status code is None
|
62
|
+
- Response body is None
|
63
|
+
- Exception with errno 110 (Connection timed out)
|
64
|
+
|
65
|
+
Args:
|
66
|
+
status_code: HTTP status code from the response (should be None)
|
67
|
+
response_body: Response body content (should be None)
|
68
|
+
exception: Exception that occurred during the request
|
69
|
+
|
70
|
+
Returns:
|
71
|
+
bool: True if this is a connection timeout, False otherwise
|
72
|
+
"""
|
73
|
+
# Check for the basic connection timeout pattern
|
74
|
+
if status_code is not None or response_body is not None:
|
75
|
+
return False
|
76
|
+
|
77
|
+
# Check if we have an exception with errno 110
|
78
|
+
if exception is None:
|
79
|
+
return False
|
80
|
+
|
81
|
+
# Check for errno 110 in various exception types
|
82
|
+
if hasattr(exception, 'errno') and exception.errno == errno.ETIMEDOUT:
|
83
|
+
return True
|
84
|
+
|
85
|
+
# Check for errno in nested exceptions (common in urllib/http.client)
|
86
|
+
if hasattr(exception, '__cause__') and exception.__cause__ is not None:
|
87
|
+
cause = exception.__cause__
|
88
|
+
if hasattr(cause, 'errno') and cause.errno == errno.ETIMEDOUT:
|
89
|
+
return True
|
90
|
+
|
91
|
+
# Check for errno in args (some exceptions store it there)
|
92
|
+
if hasattr(exception, 'args') and exception.args:
|
93
|
+
for arg in exception.args:
|
94
|
+
if hasattr(arg, 'errno') and arg.errno == errno.ETIMEDOUT:
|
95
|
+
return True
|
96
|
+
|
97
|
+
return False
|
98
|
+
|
99
|
+
@staticmethod
|
100
|
+
def is_timeout_error(
|
101
|
+
status_code: Optional[int],
|
102
|
+
response_body: Optional[str],
|
103
|
+
exception: Optional[Exception] = None
|
104
|
+
) -> bool:
|
105
|
+
"""
|
106
|
+
Unified timeout detection for both server and connection timeout scenarios.
|
107
|
+
|
108
|
+
This method combines both server timeout and connection timeout detection
|
109
|
+
to provide a single interface for timeout checking.
|
110
|
+
|
111
|
+
Args:
|
112
|
+
status_code: HTTP status code from the response
|
113
|
+
response_body: Response body content as string
|
114
|
+
exception: Exception that occurred during the request
|
115
|
+
|
116
|
+
Returns:
|
117
|
+
bool: True if this is any type of timeout error, False otherwise
|
118
|
+
"""
|
119
|
+
return (
|
120
|
+
TimeoutDetector.is_server_timeout(status_code, response_body) or
|
121
|
+
TimeoutDetector.is_connection_timeout(status_code, response_body, exception)
|
122
|
+
)
|
123
|
+
|
124
|
+
@staticmethod
|
125
|
+
def get_timeout_type(
|
126
|
+
status_code: Optional[int],
|
127
|
+
response_body: Optional[str],
|
128
|
+
exception: Optional[Exception] = None
|
129
|
+
) -> Optional[str]:
|
130
|
+
"""
|
131
|
+
Determine the type of timeout error.
|
132
|
+
|
133
|
+
Args:
|
134
|
+
status_code: HTTP status code from the response
|
135
|
+
response_body: Response body content as string
|
136
|
+
exception: Exception that occurred during the request
|
137
|
+
|
138
|
+
Returns:
|
139
|
+
str: 'server' for server timeouts, 'connection' for connection timeouts,
|
140
|
+
None if not a timeout error
|
141
|
+
"""
|
142
|
+
if TimeoutDetector.is_server_timeout(status_code, response_body):
|
143
|
+
return 'server'
|
144
|
+
elif TimeoutDetector.is_connection_timeout(status_code, response_body, exception):
|
145
|
+
return 'connection'
|
146
|
+
else:
|
147
|
+
return None
|
@@ -197,9 +197,7 @@ def extract_org_and_user_ids(token_id_url: str) -> Tuple[str, str]:
|
|
197
197
|
raise ValueError(f"Invalid token ID URL format: {token_id_url}")
|
198
198
|
|
199
199
|
|
200
|
-
def dicts_to_html_table(
|
201
|
-
items: List[Dict[str, Any]], styled: bool = False
|
202
|
-
) -> str:
|
200
|
+
def dicts_to_html_table(items: List[Dict[str, Any]], styled: bool = False) -> str:
|
203
201
|
"""
|
204
202
|
Convert a list of dictionaries to a compact HTML table.
|
205
203
|
|
@@ -309,13 +307,70 @@ def dicts_to_html_table(
|
|
309
307
|
|
310
308
|
return "".join(html)
|
311
309
|
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
for
|
310
|
+
|
311
|
+
def flatten_dict(d, parent_key="", sep="."):
|
312
|
+
"""Recursively flatten a dictionary with dot notation."""
|
313
|
+
items = {}
|
314
|
+
for k, v in d.items():
|
315
|
+
new_key = f"{parent_key}{sep}{k}" if parent_key else k
|
316
|
+
if isinstance(v, dict):
|
317
|
+
items.update(flatten_dict(v, new_key, sep=sep))
|
318
|
+
else:
|
319
|
+
items[new_key] = v
|
320
|
+
return items
|
321
|
+
|
322
|
+
|
323
|
+
def remove_attributes(obj):
|
324
|
+
"""Recursively remove 'attributes' key from dicts/lists."""
|
325
|
+
if isinstance(obj, dict):
|
326
|
+
return {k: remove_attributes(v) for k, v in obj.items() if k != "attributes"}
|
327
|
+
elif isinstance(obj, list):
|
328
|
+
return [remove_attributes(item) for item in obj]
|
329
|
+
else:
|
330
|
+
return obj
|
331
|
+
|
332
|
+
|
333
|
+
def records_to_html_table(
|
334
|
+
records: List[Dict[str, Any]], headers: Dict[str, str] = None, styled: bool = False
|
335
|
+
) -> str:
|
336
|
+
if not isinstance(records, list):
|
337
|
+
raise ValueError("records must be a list of dictionaries")
|
338
|
+
|
339
|
+
cleaned = remove_attributes(records)
|
340
|
+
|
341
|
+
flat_rows = []
|
342
|
+
for record in cleaned:
|
317
343
|
if not isinstance(record, dict):
|
318
344
|
raise ValueError(f"Record is not a dictionary: {record!r}")
|
319
|
-
|
320
|
-
|
321
|
-
|
345
|
+
flat_rows.append(flatten_dict(record))
|
346
|
+
|
347
|
+
# Preserve column order across all rows
|
348
|
+
seen = set()
|
349
|
+
ordered_columns = []
|
350
|
+
for row in flat_rows:
|
351
|
+
for key in row.keys():
|
352
|
+
if key not in seen:
|
353
|
+
ordered_columns.append(key)
|
354
|
+
seen.add(key)
|
355
|
+
|
356
|
+
# headers optionally remaps flattened field names to user-friendly display names
|
357
|
+
if headers is None:
|
358
|
+
headers = {}
|
359
|
+
for col in ordered_columns:
|
360
|
+
headers[col] = col
|
361
|
+
else:
|
362
|
+
for col in ordered_columns:
|
363
|
+
headers[col] = headers.get(col, col)
|
364
|
+
|
365
|
+
# Normalize rows so all have the same keys, using remapped column names
|
366
|
+
normalized_data = []
|
367
|
+
for row in flat_rows:
|
368
|
+
normalized_row = {
|
369
|
+
headers.get(col, col): (
|
370
|
+
"" if row.get(col, None) is None else row.get(col, "")
|
371
|
+
)
|
372
|
+
for col in ordered_columns
|
373
|
+
}
|
374
|
+
normalized_data.append(normalized_row)
|
375
|
+
|
376
|
+
return dicts_to_html_table(normalized_data, styled=styled)
|
@@ -36,6 +36,7 @@ class TestImportCompatibility:
|
|
36
36
|
CRUDError,
|
37
37
|
HTTPError,
|
38
38
|
QueryError,
|
39
|
+
QueryTimeoutError,
|
39
40
|
SFQException,
|
40
41
|
SOAPError,
|
41
42
|
)
|
@@ -44,6 +45,7 @@ class TestImportCompatibility:
|
|
44
45
|
assert issubclass(AuthenticationError, SFQException)
|
45
46
|
assert issubclass(APIError, SFQException)
|
46
47
|
assert issubclass(QueryError, APIError)
|
48
|
+
assert issubclass(QueryTimeoutError, QueryError)
|
47
49
|
assert issubclass(CRUDError, APIError)
|
48
50
|
assert issubclass(SOAPError, APIError)
|
49
51
|
assert issubclass(
|