sfq 0.0.38__tar.gz → 0.0.40__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.
Files changed (76) hide show
  1. {sfq-0.0.38 → sfq-0.0.40}/.github/workflows/publish.yml +1 -0
  2. {sfq-0.0.38 → sfq-0.0.40}/PKG-INFO +1 -1
  3. {sfq-0.0.38 → sfq-0.0.40}/pyproject.toml +1 -1
  4. {sfq-0.0.38 → sfq-0.0.40}/src/sfq/__init__.py +43 -3
  5. {sfq-0.0.38 → sfq-0.0.40}/src/sfq/auth.py +2 -0
  6. {sfq-0.0.38 → sfq-0.0.40}/src/sfq/exceptions.py +6 -0
  7. {sfq-0.0.38 → sfq-0.0.40}/src/sfq/http_client.py +149 -6
  8. sfq-0.0.40/src/sfq/timeout_detector.py +147 -0
  9. sfq-0.0.40/tests/test_SFTokenAuth_e2e.py +58 -0
  10. {sfq-0.0.38 → sfq-0.0.40}/tests/test_compatibility.py +2 -0
  11. sfq-0.0.40/tests/test_http_client_retry.py +977 -0
  12. sfq-0.0.40/tests/test_query_client_timeout_integration.py +598 -0
  13. sfq-0.0.40/tests/test_timeout_detector.py +357 -0
  14. sfq-0.0.40/tests/test_timeout_edge_cases.py +887 -0
  15. sfq-0.0.40/tests/test_timeout_scenarios_comprehensive.py +874 -0
  16. sfq-0.0.40/tests/test_timeout_scenarios_summary.py +355 -0
  17. {sfq-0.0.38 → sfq-0.0.40}/tests/test_utils.py +25 -0
  18. {sfq-0.0.38 → sfq-0.0.40}/uv.lock +1 -1
  19. {sfq-0.0.38 → sfq-0.0.40}/.gitignore +0 -0
  20. {sfq-0.0.38 → sfq-0.0.40}/.python-version +0 -0
  21. {sfq-0.0.38 → sfq-0.0.40}/README.md +0 -0
  22. {sfq-0.0.38 → sfq-0.0.40}/src/sfq/_cometd.py +0 -0
  23. {sfq-0.0.38 → sfq-0.0.40}/src/sfq/crud.py +0 -0
  24. {sfq-0.0.38 → sfq-0.0.40}/src/sfq/debug_cleanup.py +0 -0
  25. {sfq-0.0.38 → sfq-0.0.40}/src/sfq/py.typed +0 -0
  26. {sfq-0.0.38 → sfq-0.0.40}/src/sfq/query.py +0 -0
  27. {sfq-0.0.38 → sfq-0.0.40}/src/sfq/soap.py +0 -0
  28. {sfq-0.0.38 → sfq-0.0.40}/src/sfq/utils.py +0 -0
  29. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_complex_nested.html +0 -0
  30. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_complex_nested_styled.html +0 -0
  31. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_empty_list.html +0 -0
  32. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_empty_list_styled.html +0 -0
  33. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_int_float_bool.html +0 -0
  34. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_int_float_bool_styled.html +0 -0
  35. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_list_value.html +0 -0
  36. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_list_value_styled.html +0 -0
  37. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_multiple_dicts.html +0 -0
  38. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_multiple_dicts_styled.html +0 -0
  39. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_nested_dict.html +0 -0
  40. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_nested_dict_styled.html +0 -0
  41. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_none_value.html +0 -0
  42. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_none_value_styled.html +0 -0
  43. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_other_types.html +0 -0
  44. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_other_types_styled.html +0 -0
  45. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_sample_report.html +0 -0
  46. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_sample_report_styled.html +0 -0
  47. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_single_flat_dict.html +0 -0
  48. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_single_flat_dict_styled.html +0 -0
  49. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_typecastable_keys_bool.html +0 -0
  50. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_typecastable_keys_bool_styled.html +0 -0
  51. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_typecastable_keys_float.html +0 -0
  52. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_typecastable_keys_float_styled.html +0 -0
  53. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_typecastable_keys_int.html +0 -0
  54. {sfq-0.0.38 → sfq-0.0.40}/tests/html/test_typecastable_keys_int_styled.html +0 -0
  55. {sfq-0.0.38 → sfq-0.0.40}/tests/test_auth.py +0 -0
  56. {sfq-0.0.38 → sfq-0.0.40}/tests/test_cdelete.py +0 -0
  57. {sfq-0.0.38 → sfq-0.0.40}/tests/test_cquery.py +0 -0
  58. {sfq-0.0.38 → sfq-0.0.40}/tests/test_create.py +0 -0
  59. {sfq-0.0.38 → sfq-0.0.40}/tests/test_crud.py +0 -0
  60. {sfq-0.0.38 → sfq-0.0.40}/tests/test_crud_e2e.py +0 -0
  61. {sfq-0.0.38 → sfq-0.0.40}/tests/test_cupdate.py +0 -0
  62. {sfq-0.0.38 → sfq-0.0.40}/tests/test_debug_cleanup_e2e.py +0 -0
  63. {sfq-0.0.38 → sfq-0.0.40}/tests/test_debug_cleanup_unit.py +0 -0
  64. {sfq-0.0.38 → sfq-0.0.40}/tests/test_http_client.py +0 -0
  65. {sfq-0.0.38 → sfq-0.0.40}/tests/test_limits_api.py +0 -0
  66. {sfq-0.0.38 → sfq-0.0.40}/tests/test_log_trace_redact.py +0 -0
  67. {sfq-0.0.38 → sfq-0.0.40}/tests/test_open_frontdoor.py +0 -0
  68. {sfq-0.0.38 → sfq-0.0.40}/tests/test_query.py +0 -0
  69. {sfq-0.0.38 → sfq-0.0.40}/tests/test_query_client.py +0 -0
  70. {sfq-0.0.38 → sfq-0.0.40}/tests/test_query_e2e.py +0 -0
  71. {sfq-0.0.38 → sfq-0.0.40}/tests/test_query_integration.py +0 -0
  72. {sfq-0.0.38 → sfq-0.0.40}/tests/test_records_to_html.py +0 -0
  73. {sfq-0.0.38 → sfq-0.0.40}/tests/test_soap.py +0 -0
  74. {sfq-0.0.38 → sfq-0.0.40}/tests/test_soap_batch_operation.py +0 -0
  75. {sfq-0.0.38 → sfq-0.0.40}/tests/test_static_resources.py +0 -0
  76. {sfq-0.0.38 → sfq-0.0.40}/tests/test_utils_html_table.py +0 -0
@@ -94,6 +94,7 @@ jobs:
94
94
  SF_CLIENT_ID: ${{ secrets.SF_CLIENT_ID }}
95
95
  SF_CLIENT_SECRET: ${{ secrets.SF_CLIENT_SECRET }}
96
96
  SF_REFRESH_TOKEN: ${{ secrets.SF_REFRESH_TOKEN }}
97
+ SF_ACCESS_TOKEN: ${{ secrets.SF_ACCESS_TOKEN }}
97
98
 
98
99
  - name: Commit version updates
99
100
  run: |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sfq
3
- Version: 0.0.38
3
+ Version: 0.0.40
4
4
  Summary: Python wrapper for the Salesforce's Query API.
5
5
  Author-email: David Moruzzi <sfq.pypi@dmoruzi.com>
6
6
  Keywords: salesforce,salesforce query
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sfq"
3
- version = "0.0.38"
3
+ version = "0.0.40"
4
4
  description = "Python wrapper for the Salesforce's Query API."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "David Moruzzi", email = "sfq.pypi@dmoruzi.com" }]
@@ -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.38"
48
+ __version__ = "0.0.40"
47
49
  """
48
50
  ### `__version__`
49
51
 
@@ -54,6 +56,44 @@ __version__ = "0.0.38"
54
56
  """
55
57
  logger = get_logger("sfq")
56
58
 
59
+ class _SFTokenAuth:
60
+ def __init__(
61
+ self,
62
+ instance_url: str,
63
+ access_token: str,
64
+ api_version: str = "v64.0",
65
+ token_endpoint: str = "/services/oauth2/token",
66
+ user_agent: str = "sfq/0.0.40",
67
+ sforce_client: str = "_auto",
68
+ proxy: str = "_auto",
69
+ ) -> None:
70
+ from . import SFAuth
71
+
72
+ self._sf_auth = SFAuth(
73
+ instance_url=instance_url,
74
+ client_id="_",
75
+ refresh_token="_",
76
+ client_secret=str("_").strip(),
77
+ api_version=api_version,
78
+ token_endpoint=token_endpoint,
79
+ access_token=access_token,
80
+ token_expiration_time=-1.0,
81
+ user_agent=user_agent,
82
+ sforce_client=sforce_client,
83
+ proxy=proxy,
84
+ )
85
+
86
+ self._sf_auth._auth_manager.access_token = access_token
87
+ self._sf_auth._auth_manager.token_expiration_time = -1.0
88
+
89
+ def __getattr__(self, name: str) -> Any:
90
+ return getattr(self._sf_auth, name)
91
+
92
+ def __setattr__(self, name: str, value: Any) -> None:
93
+ if name == "_sf_auth":
94
+ super().__setattr__(name, value)
95
+ else:
96
+ setattr(self._sf_auth, name, value)
57
97
 
58
98
  class SFAuth:
59
99
  def __init__(
@@ -67,7 +107,7 @@ class SFAuth:
67
107
  access_token: Optional[str] = None,
68
108
  token_expiration_time: Optional[float] = None,
69
109
  token_lifetime: int = 15 * 60,
70
- user_agent: str = "sfq/0.0.38",
110
+ user_agent: str = "sfq/0.0.40",
71
111
  sforce_client: str = "_auto",
72
112
  proxy: str = "_auto",
73
113
  ) -> None:
@@ -132,7 +172,7 @@ class SFAuth:
132
172
  self._debug_cleanup = DebugCleanup(sf_auth=self)
133
173
 
134
174
  # Store version information
135
- self.__version__ = "0.0.38"
175
+ self.__version__ = "0.0.40"
136
176
  """
137
177
  ### `__version__`
138
178
 
@@ -245,6 +245,8 @@ class AuthManager:
245
245
 
246
246
  :return: True if token is expired or missing, False otherwise
247
247
  """
248
+ if self.token_expiration_time == -1.0:
249
+ return False # Token never expires
248
250
  try:
249
251
  return time.time() >= float(self.token_expiration_time)
250
252
  except (TypeError, ValueError):
@@ -30,6 +30,12 @@ class QueryError(APIError):
30
30
  pass
31
31
 
32
32
 
33
+ class QueryTimeoutError(QueryError):
34
+ """Raised when query operations timeout after all retry attempts."""
35
+
36
+ pass
37
+
38
+
33
39
  class CRUDError(APIError):
34
40
  """Raised when CRUD operations fail."""
35
41
 
@@ -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.38",
32
+ user_agent: str = "sfq/0.0.40",
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 send_authenticated_request(
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 token refresh.
197
+ Send an authenticated HTTP request with automatic timeout retry.
196
198
 
197
- This is a convenience method that handles common request patterns
198
- with authentication and standard headers.
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
@@ -0,0 +1,58 @@
1
+ """
2
+ End-to-end tests for the SFTokenAuth module.
3
+
4
+ These tests run against a real Salesforce instance using environment variables
5
+ to ensure the SFTokenAuth functionality works correctly in practice.
6
+ """
7
+
8
+ import os
9
+
10
+ import pytest
11
+
12
+ from sfq import _SFTokenAuth
13
+
14
+
15
+ @pytest.fixture(scope="module")
16
+ def sf_instance():
17
+ """Create an AuthManager instance for E2E testing."""
18
+ required_env_vars = [
19
+ "SF_INSTANCE_URL",
20
+ "SF_ACCESS_TOKEN",
21
+ ]
22
+
23
+ missing_vars = [var for var in required_env_vars if not os.getenv(var)]
24
+ if missing_vars:
25
+ pytest.fail(f"Missing required env vars: {', '.join(missing_vars)}")
26
+
27
+ sf = _SFTokenAuth(
28
+ instance_url=os.getenv("SF_INSTANCE_URL"),
29
+ access_token=os.getenv("SF_ACCESS_TOKEN"),
30
+ )
31
+ return sf
32
+
33
+
34
+ def test_query(sf_instance):
35
+ """Ensure that a simple query returns the expected results."""
36
+ query = "SELECT Id FROM FeedComment LIMIT 1"
37
+ response = sf_instance.query(query)
38
+
39
+ assert response and isinstance(response, dict), (
40
+ f"Query did not return a dict: {response}"
41
+ )
42
+
43
+ assert "records" in response, f"No records in response: {response}"
44
+ assert len(response["records"]) == 1, (
45
+ f"Expected 1 record, got {len(response['records'])}: {response}"
46
+ )
47
+ assert "Id" in response["records"][0], (
48
+ f"No Id in record: {response['records'][0]}"
49
+ )
50
+ assert response["records"][0]["Id"], (
51
+ f"Id is empty in record: {response['records'][0]}"
52
+ )
53
+ assert response["done"] is True, f"Query not marked as done: {response}"
54
+ assert "totalSize" in response, f"No totalSize in response: {response}"
55
+ assert response["totalSize"] == 1, (
56
+ f"Expected totalSize 1, got {response['totalSize']}: {response}"
57
+ )
58
+
@@ -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(