confluent-sql 0.4.0__tar.gz → 0.4.2__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 (50) hide show
  1. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/ARCHITECTURE.md +1 -1
  2. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/CHANGELOG.md +14 -0
  3. confluent_sql-0.4.2/CLAUDE.md +29 -0
  4. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/DBAPI_EXTENSIONS.md +0 -2
  5. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/PKG-INFO +2 -2
  6. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/README.md +1 -1
  7. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/STREAMING.md +0 -2
  8. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/pyproject.toml +1 -1
  9. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/connection.py +85 -53
  10. confluent_sql-0.4.2/src/confluent_sql/retry.py +82 -0
  11. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/conftest.py +2 -1
  12. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/integration/test_connection.py +44 -0
  13. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/conftest.py +0 -1
  14. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_connection_unit.py +322 -5
  15. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_cursor_unit.py +0 -58
  16. confluent_sql-0.4.2/tests/unit/test_retry_unit.py +155 -0
  17. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/.github/CODEOWNERS +0 -0
  18. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/.gitignore +0 -0
  19. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/.semaphore/publish_to_pypi.yml +0 -0
  20. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/.semaphore/semaphore.yml +0 -0
  21. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/LICENSE.txt +0 -0
  22. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/Makefile +0 -0
  23. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/TYPES.md +0 -0
  24. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/examples/errors.py +0 -0
  25. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/examples/simple_append_only_streaming_query_example.py +0 -0
  26. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/examples/snapshot_mode_tuple_cursor_simple_example.py +0 -0
  27. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/service.yml +0 -0
  28. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/__init__.py +0 -0
  29. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/__version__.py +0 -0
  30. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/changelog_compressor.py +0 -0
  31. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/cursor.py +0 -0
  32. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/exceptions.py +0 -0
  33. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/execution_mode.py +0 -0
  34. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/polling.py +0 -0
  35. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/result_readers.py +0 -0
  36. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/statement.py +0 -0
  37. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/types.py +0 -0
  38. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/__init__.py +0 -0
  39. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/integration/conftest.py +0 -0
  40. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/integration/test_cursor.py +0 -0
  41. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/integration/test_fetch.py +0 -0
  42. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_changelog_compressor_unit.py +0 -0
  43. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_changelog_unit.py +0 -0
  44. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_connection_unit_properties.py +0 -0
  45. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_execution_mode_unit.py +0 -0
  46. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_polling_unit.py +0 -0
  47. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_result_readers_unit.py +0 -0
  48. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_statement_unit.py +0 -0
  49. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_types_unit.py +0 -0
  50. {confluent_sql-0.4.0 → confluent_sql-0.4.2}/uv.lock +0 -0
@@ -45,7 +45,7 @@ Every statement progresses through these phases:
45
45
 
46
46
  ### How Phases Progress
47
47
 
48
- **Snapshot queries (the default):** _(⚠️ [Early Access](https://docs.confluent.io/cloud/current/flink/concepts/snapshot-queries.html))_
48
+ **Snapshot queries (the default):**
49
49
 
50
50
  ```
51
51
  PENDING → RUNNING → COMPLETED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this dbapi driver will be documented in this file.
4
4
 
5
+ ## 0.4.2, 2026-07-17
6
+
7
+ ### Changed
8
+
9
+ - Snapshot queries on Confluent Cloud Flink SQL are now Generally Available. Removed the Early Access warning previously emitted on first creation of a snapshot-mode cursor, along with the Early Access advisories throughout the documentation (`README.md`, `ARCHITECTURE.md`, `DBAPI_EXTENSIONS.md`, `STREAMING.md`). (#119, #160)
10
+
11
+ ## 0.4.1, 2026-07-07
12
+
13
+ ### Fixed
14
+
15
+ - Idempotent GET requests (`list_statements()`'s page-fetch loop, `get_statement()`, and result-page fetching) now retry transient transport errors -- connection resets (`httpx.NetworkError`) and servers that close pooled connections without responding (`httpx.RemoteProtocolError`) -- up to 3 times with a short exponential backoff, instead of failing on the first blip. POST/PATCH/DELETE requests (statement submission, `stop_statement()`, `delete_statement()`) are deliberately left unretried, since re-issuing them after a connection reset could double-submit or double-mutate state. (#137)
16
+ - The same idempotent GET requests now also retry transient HTTP response statuses (429, 500, 502, 503, 504), not just transport-level exceptions -- a request that reaches Confluent Cloud but gets a momentarily-overloaded gateway response was previously failing on the first such response instead of being retried like a dropped connection. (#140)
17
+ - Increased the default HTTP timeout from 5 to 10s for safety. `connect()` and `Connection.__init__()` no longer accept explicit `None` for `http_timeout_secs`.
18
+
5
19
  ## 0.4.0, 2026-06-15
6
20
 
7
21
  ### Added
@@ -0,0 +1,29 @@
1
+ # Project-specific instructions for confluent-sql
2
+
3
+ ## HTTP request routing: idempotent GETs vs mutating requests
4
+
5
+ `Connection` exposes two ways to issue an HTTP request (`src/confluent_sql/connection.py`):
6
+
7
+ - `_request_get(url, **kwargs)` — for idempotent GETs. Forwards `kwargs` (`params`, `headers`,
8
+ `timeout`, etc.) to `_request`, forcing `method="GET"`, and wraps the whole call in
9
+ `call_with_retries()` (`src/confluent_sql/retry.py`), which retries both transient transport
10
+ errors (`httpx.NetworkError`, `httpx.RemoteProtocolError`; #137) and transient HTTP response
11
+ statuses (429/500/502/503/504, via `DEFAULT_RETRYABLE_STATUS_CODES`; #140) with a short
12
+ exponential backoff before giving up. Status-code retries are wired in via a private
13
+ `_RetryableStatusError` sentinel raised inside `_request_get` -- `call_with_retries` itself
14
+ stays HTTP-agnostic, unchanged from #137.
15
+ - `_request(url, method="GET", ...)` — for everything else, including POST/PATCH/DELETE.
16
+
17
+ **Every idempotent GET call site must go through `_request_get`, not `_request` directly.**
18
+ Re-issuing a GET after a dropped connection is safe; re-issuing a POST/PATCH/DELETE is not — it
19
+ can double-submit or double-mutate state. When adding a new GET call site (e.g. a future
20
+ `list_connectors()` or similar read-only endpoint), route it through `_request_get`. When adding a
21
+ mutating call site, use `_request` directly and do not wrap it in retry logic.
22
+
23
+ This distinction was introduced in #137 (see `CHANGELOG.md` 0.4.1) after a reported
24
+ `ECONNRESET` while polling a single statement via `get_statement()`/`_get_statement()`; the same
25
+ PR additionally retries `list_statements()`'s page-fetch loop and result-page fetching, since
26
+ those are equally idempotent GETs even though they weren't the reported failure. #140 extended
27
+ the same call sites to also retry on retryable HTTP status codes, not just transport exceptions.
28
+ The reasoning, and the case for a plain function over a decorator, is captured in `retry.py`'s
29
+ module docstring and `_request_get`'s docstring.
@@ -4,8 +4,6 @@ The `confluent-sql` driver extends the standard [DB-API v2](https://peps.python.
4
4
 
5
5
  ## Understanding Snapshot vs Streaming Modes
6
6
 
7
- > **⚠️ Early Access:** [Snapshot queries](https://docs.confluent.io/cloud/current/flink/concepts/snapshot-queries.html) on Confluent Cloud Flink SQL are currently in Early Access and may be subject to change.
8
-
9
7
  **By default, the driver operates in [SNAPSHOT mode](https://docs.confluent.io/cloud/current/flink/concepts/snapshot-queries.html)**, producing behavior very similar to traditional SQL databases:
10
8
 
11
9
  - Queries execute and block until complete
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: confluent-sql
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: DB-API v2 compliant driver for Confluent Cloud Flink SQL
5
5
  Project-URL: Repository, https://github.com/confluentinc/confluent-sql
6
6
  Project-URL: Documentation, https://github.com/confluentinc/confluent-sql?tab=readme-ov-file#confluent-sql
@@ -245,7 +245,7 @@ This is pre-production code mainly developed as the lower level portion of a `db
245
245
 
246
246
  The behavior of snapshot-mode cursors, complying with dbapi semantics, are well stable. The streaming query extensions are more of a work in progress at this time. Feedback and suggestions are welcome!
247
247
 
248
- > **⚠️ Early Access:** [Snapshot queries](https://docs.confluent.io/cloud/current/flink/concepts/snapshot-queries.html) on Confluent Cloud Flink SQL are currently in Early Access and may be subject to change. You will need to request access to snapshot queries for your organization from Confluent. The driver defaults to snapshot mode for all queries unless streaming mode is explicitly requested.
248
+ The driver defaults to [snapshot mode](https://docs.confluent.io/cloud/current/flink/concepts/snapshot-queries.html) for all queries unless streaming mode is explicitly requested.
249
249
 
250
250
  ## Prerequisites
251
251
 
@@ -14,7 +14,7 @@ This is pre-production code mainly developed as the lower level portion of a `db
14
14
 
15
15
  The behavior of snapshot-mode cursors, complying with dbapi semantics, are well stable. The streaming query extensions are more of a work in progress at this time. Feedback and suggestions are welcome!
16
16
 
17
- > **⚠️ Early Access:** [Snapshot queries](https://docs.confluent.io/cloud/current/flink/concepts/snapshot-queries.html) on Confluent Cloud Flink SQL are currently in Early Access and may be subject to change. You will need to request access to snapshot queries for your organization from Confluent. The driver defaults to snapshot mode for all queries unless streaming mode is explicitly requested.
17
+ The driver defaults to [snapshot mode](https://docs.confluent.io/cloud/current/flink/concepts/snapshot-queries.html) for all queries unless streaming mode is explicitly requested.
18
18
 
19
19
  ## Prerequisites
20
20
 
@@ -4,8 +4,6 @@ The `confluent-sql` driver provides full support for continuous streaming querie
4
4
 
5
5
  ## Overview
6
6
 
7
- > **⚠️ Early Access:** [Snapshot queries](https://docs.confluent.io/cloud/current/flink/concepts/snapshot-queries.html) on Confluent Cloud Flink SQL are currently in Early Access and may be subject to change. The SNAPSHOT mode described below relies on this feature.
8
-
9
7
  This driver supports two primary execution modes:
10
8
 
11
9
  | Mode | Use Case | Query Examples | Result Behavior |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "confluent-sql"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "DB-API v2 compliant driver for Confluent Cloud Flink SQL"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -29,12 +29,22 @@ from .exceptions import (
29
29
  )
30
30
  from .execution_mode import ExecutionMode
31
31
  from .polling import sleep_with_backoff
32
+ from .retry import (
33
+ DEFAULT_RETRYABLE_EXCEPTIONS,
34
+ DEFAULT_RETRYABLE_STATUS_CODES,
35
+ call_with_retries,
36
+ )
32
37
  from .statement import LABEL_PREFIX as STATEMENT_LABEL_PREFIX
33
38
  from .statement import ChangelogRow, Statement
34
39
  from .types import PropertiesDict, RowPythonTypes
35
40
 
36
41
  logger = logging.getLogger(__name__)
37
42
 
43
+ DEFAULT_HTTP_TIMEOUT_SECS: float = 10.0
44
+ """Applied to the httpx client whenever a caller doesn't pass http_timeout_secs explicitly,
45
+ so the effective default is ours to control rather than an implicit dependency on httpx's own
46
+ default (5s)."""
47
+
38
48
 
39
49
  def _resolve_api_credentials(
40
50
  global_api_key: str | None,
@@ -96,7 +106,7 @@ def connect( # noqa: PLR0913
96
106
  dbname: str | None = None, # deprecated, use database parameter
97
107
  result_page_fetch_pause_millis: int = 100,
98
108
  http_user_agent: str | None = None,
99
- http_timeout_secs: float | None = None,
109
+ http_timeout_secs: float = DEFAULT_HTTP_TIMEOUT_SECS,
100
110
  ) -> Connection:
101
111
  """
102
112
  Create a connection to a Confluent SQL service.
@@ -146,8 +156,8 @@ def connect( # noqa: PLR0913
146
156
  "Confluent-SQL-Dbapi/v<version> (https://confluent.io; support@confluent.io)"
147
157
  where version is from __version__.py
148
158
  http_timeout_secs: Timeout in seconds for HTTP requests made by the underlying httpx
149
- client. Must be a positive number. If None (default), the httpx default
150
- of 5 seconds applies to connect, read, write, and pool operations.
159
+ client. Must be a positive number. Defaults to DEFAULT_HTTP_TIMEOUT_SECS,
160
+ applied to connect, read, write, and pool operations.
151
161
 
152
162
  Returns:
153
163
  A Connection object representing the database connection
@@ -205,6 +215,15 @@ def connect( # noqa: PLR0913
205
215
  )
206
216
 
207
217
 
218
+ class _RetryableStatusError(Exception):
219
+ """Wraps a response carrying a retryable HTTP status (#140) so call_with_retries' existing
220
+ exception-based retry loop drives status-code retries too. Never escapes _request_get."""
221
+
222
+ def __init__(self, response: httpx.Response):
223
+ self.response = response
224
+ super().__init__(f"transient HTTP status {response.status_code}")
225
+
226
+
208
227
  class Connection:
209
228
  """
210
229
  A connection to a Confluent SQL service.
@@ -239,15 +258,11 @@ class Connection:
239
258
  _database: str | None
240
259
  _client: httpx.Client
241
260
  _http_user_agent: str
242
- _http_timeout_secs: float | None
261
+ _http_timeout_secs: float
243
262
 
244
263
  _row_type_registry: RowTypeRegistry
245
264
  """Registry for user-defined row types, see register_row_type()."""
246
265
 
247
- _snapshot_warning_issued: bool
248
- """Internal flag to track whether the snapshot query early access warning has been issued.
249
- Remove after snapshot queries reach open preview (expected May 2026)."""
250
-
251
266
  def __init__( # noqa: PLR0913
252
267
  self,
253
268
  environment_id: str,
@@ -264,7 +279,7 @@ class Connection:
264
279
  database: str | None = None,
265
280
  statement_results_page_fetch_pause_millis: int = 100,
266
281
  http_user_agent: str | None = None,
267
- http_timeout_secs: float | None = None,
282
+ http_timeout_secs: float = DEFAULT_HTTP_TIMEOUT_SECS,
268
283
  ):
269
284
  """
270
285
  Initialize a new connection to a Confluent SQL service.
@@ -303,8 +318,7 @@ class Connection:
303
318
  Defaults to the value of DEFAULT_USER_AGENT, which includes the
304
319
  driver name/version, documentation URL, and support email.
305
320
  http_timeout_secs: Timeout in seconds applied to the underlying httpx client.
306
- Must be a positive number. If None (default), the httpx default
307
- of 5 seconds applies.
321
+ Must be a positive number. Defaults to DEFAULT_HTTP_TIMEOUT_SECS.
308
322
  """
309
323
  self.environment_id = environment_id
310
324
  # Fold a falsy pool ("" or None) into None so the attribute honestly reports the
@@ -330,20 +344,14 @@ class Connection:
330
344
  http_user_agent if http_user_agent is not None else self.DEFAULT_USER_AGENT
331
345
  )
332
346
 
333
- if http_timeout_secs is not None:
334
- # Reject bool explicitly: bool is a subclass of int in Python, but a True/False
335
- # value here is almost certainly a programming error rather than a valid timeout.
336
- if isinstance(http_timeout_secs, bool) or not isinstance(
337
- http_timeout_secs, (int, float)
338
- ):
339
- raise InterfaceError(
340
- f"http_timeout_secs must be a number, "
341
- f"got {type(http_timeout_secs).__name__}"
342
- )
343
- if http_timeout_secs <= 0:
344
- raise InterfaceError(
345
- f"http_timeout_secs must be positive, got {http_timeout_secs}"
346
- )
347
+ # Reject bool explicitly: bool is a subclass of int in Python, but a True/False
348
+ # value here is almost certainly a programming error rather than a valid timeout.
349
+ if isinstance(http_timeout_secs, bool) or not isinstance(http_timeout_secs, (int, float)):
350
+ raise InterfaceError(
351
+ f"http_timeout_secs must be a number, got {type(http_timeout_secs).__name__}"
352
+ )
353
+ if http_timeout_secs <= 0:
354
+ raise InterfaceError(f"http_timeout_secs must be positive, got {http_timeout_secs}")
347
355
  self._http_timeout_secs = http_timeout_secs
348
356
 
349
357
  if not endpoint and not (cloud_provider and cloud_region):
@@ -377,15 +385,11 @@ class Connection:
377
385
  "User-Agent": self._http_user_agent,
378
386
  },
379
387
  }
380
- if self._http_timeout_secs is not None:
381
- client_kwargs["timeout"] = self._http_timeout_secs
388
+ client_kwargs["timeout"] = self._http_timeout_secs
382
389
  self._client = httpx.Client(**client_kwargs)
383
390
 
384
391
  self._row_type_registry = RowTypeRegistry()
385
392
 
386
- # TODO: remove after snapshot queries reach open preview (May 2026)
387
- self._snapshot_warning_issued = False
388
-
389
393
  def close(self) -> None:
390
394
  """
391
395
  Close the connection.
@@ -498,15 +502,6 @@ class Connection:
498
502
  if self._closed:
499
503
  raise InterfaceError("Connection is closed")
500
504
 
501
- # TODO: remove after snapshot queries reach open preview (May 2026)
502
- if mode.is_snapshot and not self._snapshot_warning_issued:
503
- self._snapshot_warning_issued = True
504
- warnings.warn(
505
- "Snapshot queries on Confluent Cloud Flink SQL are currently in "
506
- "Early Access and may be subject to change.",
507
- stacklevel=2,
508
- )
509
-
510
505
  return Cursor(self, as_dict=as_dict, execution_mode=mode)
511
506
 
512
507
  def streaming_cursor(self, *, as_dict: bool = False) -> Cursor:
@@ -811,7 +806,7 @@ class Connection:
811
806
  has_more_pages = True
812
807
  next_page_token: str | None = None
813
808
  while has_more_pages:
814
- response = self._request("/statements", params=parameters)
809
+ response = self._request_get("/statements", params=parameters)
815
810
  resp_json = response.json()
816
811
  statements_json = resp_json.get("data", [])
817
812
  statements.extend(Statement.from_response(self, s) for s in statements_json)
@@ -1057,10 +1052,10 @@ class Connection:
1057
1052
  return self._closed
1058
1053
 
1059
1054
  @property
1060
- def http_timeout_secs(self) -> float | None:
1055
+ def http_timeout_secs(self) -> float:
1061
1056
  """
1062
- Get the configured timeout (in seconds) for HTTP requests, or None if the
1063
- httpx default (5 seconds) is in effect.
1057
+ Get the effective timeout (in seconds) applied to HTTP requests, which is
1058
+ DEFAULT_HTTP_TIMEOUT_SECS unless a value was supplied at construction time.
1064
1059
  """
1065
1060
  return self._http_timeout_secs
1066
1061
 
@@ -1319,7 +1314,7 @@ class Connection:
1319
1314
  OperationalError: If other API errors occur
1320
1315
  """
1321
1316
  try:
1322
- return self._request(f"/statements/{statement_name}").json()
1317
+ return self._request_get(f"/statements/{statement_name}").json()
1323
1318
  except OperationalError as e:
1324
1319
  # Check if this is a 404 error
1325
1320
  if e.http_status_code == 404:
@@ -1353,7 +1348,7 @@ class Connection:
1353
1348
  next_url = f"/statements/{statement_name}/results"
1354
1349
 
1355
1350
  try:
1356
- response = self._request(next_url).json()
1351
+ response = self._request_get(next_url).json()
1357
1352
  except OperationalError as e:
1358
1353
  # Check if this is a 404 error indicating the statement was deleted
1359
1354
  if e.http_status_code == 404:
@@ -1402,16 +1397,43 @@ class Connection:
1402
1397
 
1403
1398
  return (results, next_url)
1404
1399
 
1405
- def _request(self, url, method="GET", raise_for_status=True, **kwargs) -> httpx.Response:
1406
- if self._closed:
1407
- raise InterfaceError("Connection is closed")
1400
+ def _request_get(self, url, **kwargs) -> httpx.Response:
1401
+ """Issue a GET, retrying transient transport errors (#137) and transient HTTP status
1402
+ codes (#140) alike -- both feed the same call_with_retries backoff/attempt budget.
1403
+
1404
+ Only for idempotent GETs -- retrying a call with side effects (POST/PATCH/DELETE) could
1405
+ double-submit or double-mutate state, so those call sites use `_request` directly.
1406
+ Forwards arbitrary kwargs (params, headers, timeout, etc.) to `_request`, forcing
1407
+ `method="GET"` regardless of what's passed, so a call site cannot accidentally opt out of
1408
+ the retry policy just because it needs a kwarg beyond `params`. `raise_for_status` is
1409
+ dropped if present -- this method always manages it internally, since it must inspect
1410
+ the raw response to decide whether to retry before translating a final error.
1411
+ """
1412
+ kwargs["method"] = "GET"
1413
+ kwargs.pop("raise_for_status", None)
1408
1414
 
1409
- try:
1410
- response = self._client.request(method, url, **kwargs)
1411
- logger.debug("Response: %s", response.content)
1412
- if raise_for_status:
1413
- response.raise_for_status()
1415
+ def _get_or_flag_retryable_status() -> httpx.Response:
1416
+ response = self._request(url, raise_for_status=False, **kwargs)
1417
+ if response.status_code in DEFAULT_RETRYABLE_STATUS_CODES:
1418
+ raise _RetryableStatusError(response)
1414
1419
  return response
1420
+
1421
+ try:
1422
+ response = call_with_retries(
1423
+ _get_or_flag_retryable_status,
1424
+ exceptions=(*DEFAULT_RETRYABLE_EXCEPTIONS, _RetryableStatusError),
1425
+ )
1426
+ except _RetryableStatusError as e:
1427
+ response = e.response
1428
+
1429
+ self._raise_for_status_as_operational_error(response)
1430
+ return response
1431
+
1432
+ def _raise_for_status_as_operational_error(self, response: httpx.Response) -> None:
1433
+ """Translate a 4xx/5xx response into OperationalError, chaining the original
1434
+ httpx.HTTPStatusError and including any server-provided error detail."""
1435
+ try:
1436
+ response.raise_for_status()
1415
1437
  except httpx.HTTPStatusError as e:
1416
1438
  try:
1417
1439
  res = e.response.json()
@@ -1425,6 +1447,16 @@ class Connection:
1425
1447
  http_status_code=e.response.status_code,
1426
1448
  ) from e
1427
1449
 
1450
+ def _request(self, url, method="GET", raise_for_status=True, **kwargs) -> httpx.Response:
1451
+ if self._closed:
1452
+ raise InterfaceError("Connection is closed")
1453
+
1454
+ response = self._client.request(method, url, **kwargs)
1455
+ logger.debug("Response: %s", response.content)
1456
+ if raise_for_status:
1457
+ self._raise_for_status_as_operational_error(response)
1458
+ return response
1459
+
1428
1460
  def _get_next_page_token(self, next_url: str | None) -> str | None:
1429
1461
  """Extract the next page token from the next_url, if present."""
1430
1462
  if next_url is None:
@@ -0,0 +1,82 @@
1
+ """Retry helper for idempotent, side-effect-free calls.
2
+
3
+ Centralizes retrying of calls that intermittently fail with transient transport errors, so this
4
+ policy lives in one place rather than being reimplemented at each call site. Only appropriate for
5
+ calls where re-issuing after a partial failure is safe -- retrying a call with side effects (e.g. a
6
+ POST that already reached the server before the response was lost) could double-submit or
7
+ double-mutate state.
8
+ """
9
+
10
+ import logging
11
+ import random
12
+ import time
13
+ from collections.abc import Callable
14
+ from typing import TypeVar
15
+
16
+ import httpx
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ T = TypeVar("T")
21
+
22
+ DEFAULT_RETRYABLE_EXCEPTIONS: tuple[type[BaseException], ...] = (
23
+ httpx.NetworkError,
24
+ httpx.RemoteProtocolError,
25
+ )
26
+ """Transient transport errors worth retrying: connection resets/drops (httpx.NetworkError covers
27
+ ConnectError/ReadError/WriteError/CloseError) and servers that close pooled connections without
28
+ responding (httpx.RemoteProtocolError). httpx.TimeoutException is deliberately excluded --
29
+ retrying a timeout compounds latency (up to (max_retries + 1)x the configured timeout) rather than
30
+ recovering from a blip; pass a custom `exceptions` tuple to opt in."""
31
+
32
+ DEFAULT_RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429, 500, 502, 503, 504})
33
+ """HTTP statuses worth retrying on an idempotent GET: 429 (rate limited) and 500/502/503/504
34
+ (gateway/server transients). Consumed by `Connection._request_get`, not by `call_with_retries`
35
+ itself, which has no HTTP-specific knowledge. 408 (Request Timeout) is deliberately excluded --
36
+ same "don't compound latency on a timeout" reasoning as excluding httpx.TimeoutException from
37
+ DEFAULT_RETRYABLE_EXCEPTIONS above. Honoring a Retry-After header on 429/503 is out of scope."""
38
+
39
+ _RETRY_BASE_DELAY_SECS = 0.1
40
+ """Delay before the first retry sleep -- tuned for sub-second transient network blips rather than
41
+ the multi-second server-state polling that polling.sleep_with_backoff paces."""
42
+
43
+ _RETRY_DELAY_GROWTH = 2.0
44
+ """Multiplier applied to the delay after each retry."""
45
+
46
+ _RETRY_JITTER_BAND = (0.75, 1.25)
47
+ """Multiplicative jitter band (+-25%) applied to each sleep to avoid thundering-herd alignment."""
48
+
49
+
50
+ def call_with_retries(
51
+ func: Callable[..., T],
52
+ *args,
53
+ max_retries: int = 3,
54
+ exceptions: tuple[type[BaseException], ...] = DEFAULT_RETRYABLE_EXCEPTIONS,
55
+ **kwargs,
56
+ ) -> T:
57
+ """Call func(*args, **kwargs), retrying up to max_retries times on the given exceptions.
58
+
59
+ Sleeps a short exponential backoff with jitter between attempts and logs one INFO line per
60
+ retry naming the attempt number. After max_retries retries (max_retries + 1 total attempts),
61
+ the original exception from the final attempt is re-raised.
62
+ """
63
+ delay = _RETRY_BASE_DELAY_SECS
64
+ attempt = 0
65
+ while True:
66
+ try:
67
+ return func(*args, **kwargs)
68
+ except exceptions as e:
69
+ attempt += 1
70
+ if attempt > max_retries:
71
+ raise
72
+ sleep_secs = delay * random.uniform(*_RETRY_JITTER_BAND)
73
+ logger.info(
74
+ 'Retrying %s after transient error (attempt %d/%d): "%s"; sleeping %.3fs',
75
+ getattr(func, "__name__", repr(func)),
76
+ attempt,
77
+ max_retries,
78
+ e,
79
+ sleep_secs,
80
+ )
81
+ time.sleep(sleep_secs)
82
+ delay *= _RETRY_DELAY_GROWTH
@@ -7,6 +7,7 @@ from typing import TypeAlias
7
7
  import pytest
8
8
 
9
9
  from confluent_sql import Connection, connect
10
+ from confluent_sql.connection import DEFAULT_HTTP_TIMEOUT_SECS
10
11
 
11
12
 
12
13
  def pytest_runtest_setup(item):
@@ -61,7 +62,7 @@ def connection_factory() -> Generator[ConnectionFactory, None, None]:
61
62
  database: str | None = None,
62
63
  result_page_fetch_pause_millis: int = 100,
63
64
  http_user_agent: str | None = None,
64
- http_timeout_secs: float | None = None,
65
+ http_timeout_secs: float = DEFAULT_HTTP_TIMEOUT_SECS,
65
66
  endpoint: str | None = None,
66
67
  ) -> Connection:
67
68
  if global_api_key is None:
@@ -8,6 +8,7 @@ Credentials must be provided via environment variables.
8
8
  import os
9
9
  import time
10
10
 
11
+ import httpx
11
12
  import pytest
12
13
 
13
14
  import confluent_sql
@@ -273,6 +274,49 @@ class TestConnection:
273
274
  assert exc_info.value.statement_name == "non-existent-statement-name"
274
275
 
275
276
 
277
+ @pytest.mark.integration
278
+ class TestRetryOnTransientResultFetchErrors:
279
+ """Integration coverage for #137's retry-on-idempotent-GET behavior."""
280
+
281
+ def test_result_fetch_retries_past_simulated_econnresets_then_hits_confluent_cloud_for_real(
282
+ self, connection: Connection, mocker
283
+ ):
284
+ """Simulates two ECONNRESET-style drops on the results-fetch GET, then lets the third
285
+ attempt travel all the way through to Confluent Cloud for real.
286
+
287
+ NOTE: this is largely redundant with the fully-mocked coverage in
288
+ tests/unit/test_retry_unit.py and TestConnectionRetriesIdempotentGets
289
+ (tests/unit/test_connection_unit.py) -- those already prove call_with_retries' counting,
290
+ backoff, and exception-filtering, and that Connection wires it into
291
+ _get_statement_results correctly. It's written anyway, against a real environment, in
292
+ the spirit of treating #137 as a release-branch bugfix: showing the retry survives an
293
+ actual round trip is worth the extra network call, not just asserting it in the abstract.
294
+ """
295
+ with connection.closing_cursor() as cursor:
296
+ cursor.execute("SELECT 1 as answer FROM `INFORMATION_SCHEMA`.`TABLES`")
297
+
298
+ # Capture the real bound method before patching so the third call can still reach
299
+ # Confluent Cloud for real.
300
+ real_request = connection._client.request
301
+ call_count = 0
302
+
303
+ def flaky_then_real_request(*args, **kwargs):
304
+ nonlocal call_count
305
+ call_count += 1
306
+ if call_count <= 2:
307
+ raise httpx.ReadError("[Errno 54] Connection reset by peer")
308
+ return real_request(*args, **kwargs)
309
+
310
+ request_mock = mocker.patch.object(
311
+ connection._client, "request", side_effect=flaky_then_real_request
312
+ )
313
+
314
+ row = cursor.fetchone()
315
+
316
+ assert row == (1,)
317
+ assert request_mock.call_count == 3
318
+
319
+
276
320
  @pytest.mark.integration
277
321
  class TestStopStatement:
278
322
  """Integration tests for stop_statement against a real environment."""
@@ -239,7 +239,6 @@ def mock_connection_factory(mocker, statement_response_factory) -> MockConnectio
239
239
  mock_conn.closing_cursor = types.MethodType(Connection.closing_cursor, mock_conn)
240
240
 
241
241
  mock_conn._row_type_registry = RowTypeRegistry()
242
- mock_conn._snapshot_warning_issued = False
243
242
 
244
243
  return mock_conn
245
244
 
@@ -11,6 +11,7 @@ import pytest
11
11
  from confluent_sql import InterfaceError, OperationalError, StatementNotFoundError
12
12
  from confluent_sql.__version__ import VERSION
13
13
  from confluent_sql.connection import (
14
+ DEFAULT_HTTP_TIMEOUT_SECS,
14
15
  Connection,
15
16
  RowTypeRegistry,
16
17
  _resolve_api_credentials,
@@ -1561,6 +1562,317 @@ class TestGetStatement:
1561
1562
  logger_info_spy.assert_called_with("Getting statement 'log-test-statement'")
1562
1563
 
1563
1564
 
1565
+ @pytest.mark.unit
1566
+ class TestConnectionRetriesIdempotentGets:
1567
+ """Tests that idempotent GET paths retry transient transport errors (#137), while
1568
+ mutating (POST/PATCH/DELETE) paths deliberately do not."""
1569
+
1570
+ def test_list_statements_retries_and_succeeds(
1571
+ self,
1572
+ invalid_credential_connection: Connection,
1573
+ statement_response_factory: StatementResponseFactory,
1574
+ mocker,
1575
+ ):
1576
+ mocker.patch("confluent_sql.retry.time.sleep")
1577
+ mock_response = Mock()
1578
+ mock_response.json.return_value = {
1579
+ "data": [statement_response_factory(name="stmt-1")],
1580
+ "metadata": {},
1581
+ }
1582
+ request_mock = mocker.patch.object(
1583
+ invalid_credential_connection._client,
1584
+ "request",
1585
+ side_effect=[httpx.ReadError("reset"), mock_response],
1586
+ )
1587
+
1588
+ statements = invalid_credential_connection.list_statements()
1589
+
1590
+ assert [s.name for s in statements] == ["stmt-1"]
1591
+ assert request_mock.call_count == 2
1592
+
1593
+ def test_get_statement_retries_and_succeeds(
1594
+ self,
1595
+ invalid_credential_connection: Connection,
1596
+ statement_response_factory: StatementResponseFactory,
1597
+ mocker,
1598
+ ):
1599
+ mocker.patch("confluent_sql.retry.time.sleep")
1600
+ mock_response = Mock()
1601
+ mock_response.json.return_value = statement_response_factory(name="stmt-1")
1602
+ request_mock = mocker.patch.object(
1603
+ invalid_credential_connection._client,
1604
+ "request",
1605
+ side_effect=[httpx.ReadError("reset"), mock_response],
1606
+ )
1607
+
1608
+ result = invalid_credential_connection._get_statement("stmt-1")
1609
+
1610
+ assert result["name"] == "stmt-1"
1611
+ assert request_mock.call_count == 2
1612
+
1613
+ def test_get_statement_results_retries_and_succeeds(
1614
+ self,
1615
+ invalid_credential_connection: Connection,
1616
+ mocker,
1617
+ ):
1618
+ mocker.patch("confluent_sql.retry.time.sleep")
1619
+ mock_response = Mock()
1620
+ mock_response.json.return_value = {
1621
+ "results": {"data": [{"row": ["v1"]}]},
1622
+ "metadata": {},
1623
+ }
1624
+ request_mock = mocker.patch.object(
1625
+ invalid_credential_connection._client,
1626
+ "request",
1627
+ side_effect=[httpx.ReadError("reset"), mock_response],
1628
+ )
1629
+
1630
+ results, next_url = invalid_credential_connection._get_statement_results("stmt-1", None)
1631
+
1632
+ assert [r.row for r in results] == [["v1"]]
1633
+ assert next_url is None
1634
+ assert request_mock.call_count == 2
1635
+
1636
+ def test_idempotent_get_reraises_original_exception_after_exhausting_retries(
1637
+ self,
1638
+ invalid_credential_connection: Connection,
1639
+ mocker,
1640
+ ):
1641
+ """Documents today's unwrapped-transport-error behavior (tracked as #138): after retries
1642
+ are exhausted, the raw httpx exception propagates rather than an OperationalError."""
1643
+ mocker.patch("confluent_sql.retry.time.sleep")
1644
+ request_mock = mocker.patch.object(
1645
+ invalid_credential_connection._client,
1646
+ "request",
1647
+ side_effect=[httpx.ReadError("reset")] * 4,
1648
+ )
1649
+
1650
+ with pytest.raises(httpx.ReadError):
1651
+ invalid_credential_connection._get_statement("stmt-1")
1652
+
1653
+ assert request_mock.call_count == 4
1654
+
1655
+ def test_non_idempotent_path_does_not_retry_on_transient_error(
1656
+ self,
1657
+ invalid_credential_connection: Connection,
1658
+ mocker,
1659
+ ):
1660
+ """delete_statement's DELETE must not be retried: retrying a mutating call after a
1661
+ connection reset could double-mutate state, which is exactly what #137 scopes retries
1662
+ away from."""
1663
+ request_mock = mocker.patch.object(
1664
+ invalid_credential_connection._client,
1665
+ "request",
1666
+ side_effect=httpx.ReadError("reset"),
1667
+ )
1668
+
1669
+ with pytest.raises(httpx.ReadError):
1670
+ invalid_credential_connection.delete_statement("stmt-1")
1671
+
1672
+ request_mock.assert_called_once()
1673
+
1674
+ def test_list_statements_retries_on_retryable_status_and_succeeds(
1675
+ self,
1676
+ invalid_credential_connection: Connection,
1677
+ statement_response_factory: StatementResponseFactory,
1678
+ mocker,
1679
+ ):
1680
+ mocker.patch("confluent_sql.retry.time.sleep")
1681
+ mock_response = Mock()
1682
+ mock_response.status_code = 200
1683
+ mock_response.json.return_value = {
1684
+ "data": [statement_response_factory(name="stmt-1")],
1685
+ "metadata": {},
1686
+ }
1687
+ request_mock = mocker.patch.object(
1688
+ invalid_credential_connection._client,
1689
+ "request",
1690
+ side_effect=[_http_error_response(503), mock_response],
1691
+ )
1692
+
1693
+ statements = invalid_credential_connection.list_statements()
1694
+
1695
+ assert [s.name for s in statements] == ["stmt-1"]
1696
+ assert request_mock.call_count == 2
1697
+
1698
+ def test_get_statement_retries_on_retryable_status_and_succeeds(
1699
+ self,
1700
+ invalid_credential_connection: Connection,
1701
+ statement_response_factory: StatementResponseFactory,
1702
+ mocker,
1703
+ ):
1704
+ mocker.patch("confluent_sql.retry.time.sleep")
1705
+ mock_response = Mock()
1706
+ mock_response.status_code = 200
1707
+ mock_response.json.return_value = statement_response_factory(name="stmt-1")
1708
+ request_mock = mocker.patch.object(
1709
+ invalid_credential_connection._client,
1710
+ "request",
1711
+ side_effect=[_http_error_response(503), mock_response],
1712
+ )
1713
+
1714
+ result = invalid_credential_connection._get_statement("stmt-1")
1715
+
1716
+ assert result["name"] == "stmt-1"
1717
+ assert request_mock.call_count == 2
1718
+
1719
+ def test_get_statement_results_retries_on_retryable_status_and_succeeds(
1720
+ self,
1721
+ invalid_credential_connection: Connection,
1722
+ mocker,
1723
+ ):
1724
+ mocker.patch("confluent_sql.retry.time.sleep")
1725
+ mock_response = Mock()
1726
+ mock_response.status_code = 200
1727
+ mock_response.json.return_value = {
1728
+ "results": {"data": [{"row": ["v1"]}]},
1729
+ "metadata": {},
1730
+ }
1731
+ request_mock = mocker.patch.object(
1732
+ invalid_credential_connection._client,
1733
+ "request",
1734
+ side_effect=[_http_error_response(503), mock_response],
1735
+ )
1736
+
1737
+ results, next_url = invalid_credential_connection._get_statement_results("stmt-1", None)
1738
+
1739
+ assert [r.row for r in results] == [["v1"]]
1740
+ assert next_url is None
1741
+ assert request_mock.call_count == 2
1742
+
1743
+ def test_idempotent_get_raises_operational_error_after_exhausting_status_retries(
1744
+ self,
1745
+ invalid_credential_connection: Connection,
1746
+ mocker,
1747
+ ):
1748
+ """Unlike #137's transport-exception exhaustion (which leaks the raw httpx exception,
1749
+ tracked as #138), a persistently-retryable status IS translated to OperationalError --
1750
+ _raise_for_status_as_operational_error runs on the final response either way."""
1751
+ mocker.patch("confluent_sql.retry.time.sleep")
1752
+ request_mock = mocker.patch.object(
1753
+ invalid_credential_connection._client,
1754
+ "request",
1755
+ side_effect=[_http_error_response(503)] * 4,
1756
+ )
1757
+
1758
+ with pytest.raises(OperationalError) as exc_info:
1759
+ invalid_credential_connection._get_statement("stmt-1")
1760
+
1761
+ assert exc_info.value.http_status_code == 503
1762
+ assert request_mock.call_count == 4
1763
+
1764
+ def test_get_statement_does_not_retry_non_retryable_status(
1765
+ self,
1766
+ invalid_credential_connection: Connection,
1767
+ mocker,
1768
+ ):
1769
+ """A 404 must not be treated as retryable -- it's the caller's job to see
1770
+ StatementNotFoundError on the very first response, not after a wasted retry budget."""
1771
+ request_mock = mocker.patch.object(
1772
+ invalid_credential_connection._client,
1773
+ "request",
1774
+ return_value=_http_error_response(404),
1775
+ )
1776
+
1777
+ with pytest.raises(StatementNotFoundError):
1778
+ invalid_credential_connection._get_statement("stmt-1")
1779
+
1780
+ request_mock.assert_called_once()
1781
+
1782
+ def test_get_statement_retries_share_budget_across_transient_error_and_status(
1783
+ self,
1784
+ invalid_credential_connection: Connection,
1785
+ statement_response_factory: StatementResponseFactory,
1786
+ mocker,
1787
+ ):
1788
+ """A transport exception and a retryable status draw from the same attempt/backoff
1789
+ budget -- proof that #140's status-code retries reuse #137's call_with_retries loop
1790
+ rather than adding a second, independent retry mechanism."""
1791
+ mocker.patch("confluent_sql.retry.time.sleep")
1792
+ mock_response = Mock()
1793
+ mock_response.status_code = 200
1794
+ mock_response.json.return_value = statement_response_factory(name="stmt-1")
1795
+ request_mock = mocker.patch.object(
1796
+ invalid_credential_connection._client,
1797
+ "request",
1798
+ side_effect=[httpx.ReadError("reset"), _http_error_response(503), mock_response],
1799
+ )
1800
+
1801
+ result = invalid_credential_connection._get_statement("stmt-1")
1802
+
1803
+ assert result["name"] == "stmt-1"
1804
+ assert request_mock.call_count == 3
1805
+
1806
+ def test_delete_statement_does_not_retry_on_retryable_status(
1807
+ self,
1808
+ invalid_credential_connection: Connection,
1809
+ mocker,
1810
+ ):
1811
+ """delete_statement's DELETE must not be retried on a retryable status either -- #140's
1812
+ status-code check must live in _request_get, not leak into _request itself."""
1813
+ request_mock = mocker.patch.object(
1814
+ invalid_credential_connection._client,
1815
+ "request",
1816
+ return_value=_http_error_response(503),
1817
+ )
1818
+
1819
+ with pytest.raises(OperationalError, match="Error deleting statement"):
1820
+ invalid_credential_connection.delete_statement("stmt-1")
1821
+
1822
+ request_mock.assert_called_once()
1823
+
1824
+ def test_request_get_forwards_arbitrary_kwargs_to_request(
1825
+ self,
1826
+ invalid_credential_connection: Connection,
1827
+ mocker,
1828
+ ):
1829
+ """A future GET call site needing e.g. headers/timeout must not have to bypass
1830
+ _request_get (and thus the retry policy) just because _request_get only knew about
1831
+ `params`."""
1832
+ request_mock = mocker.patch.object(
1833
+ invalid_credential_connection._client, "request", return_value=Mock()
1834
+ )
1835
+
1836
+ invalid_credential_connection._request_get(
1837
+ "/statements", headers={"X-Test": "1"}, timeout=5
1838
+ )
1839
+
1840
+ request_mock.assert_called_once_with(
1841
+ "GET", "/statements", headers={"X-Test": "1"}, timeout=5
1842
+ )
1843
+
1844
+ def test_request_get_forces_get_method_even_if_caller_passes_method(
1845
+ self,
1846
+ invalid_credential_connection: Connection,
1847
+ mocker,
1848
+ ):
1849
+ """_request_get must never issue anything but a GET, even if a caller mistakenly
1850
+ passes a method= kwarg -- that guarantee is what makes it safe to retry."""
1851
+ request_mock = mocker.patch.object(
1852
+ invalid_credential_connection._client, "request", return_value=Mock()
1853
+ )
1854
+
1855
+ invalid_credential_connection._request_get("/statements", method="POST")
1856
+
1857
+ request_mock.assert_called_once_with("GET", "/statements")
1858
+
1859
+ def test_request_get_drops_caller_supplied_raise_for_status_kwarg(
1860
+ self,
1861
+ invalid_credential_connection: Connection,
1862
+ mocker,
1863
+ ):
1864
+ """_request_get always manages raise_for_status itself (it must see the raw response
1865
+ to decide whether to retry before translating it) -- a caller passing raise_for_status
1866
+ in kwargs must not collide with that and blow up with a duplicate-keyword TypeError."""
1867
+ request_mock = mocker.patch.object(
1868
+ invalid_credential_connection._client, "request", return_value=Mock()
1869
+ )
1870
+
1871
+ invalid_credential_connection._request_get("/statements", raise_for_status=True)
1872
+
1873
+ request_mock.assert_called_once_with("GET", "/statements")
1874
+
1875
+
1564
1876
  @pytest.mark.unit
1565
1877
  class TestExecuteStatement:
1566
1878
  """Tests for _execute_statement method."""
@@ -2104,13 +2416,18 @@ class TestHttpUserAgentProperty:
2104
2416
  class TestHttpTimeoutSecs:
2105
2417
  """Tests for the http_timeout_secs constructor parameter and property."""
2106
2418
 
2107
- def test_default_is_none_and_httpx_default_applies(
2419
+ def test_default_reports_confluent_sql_default(
2108
2420
  self, invalid_credential_connection: Connection
2109
2421
  ):
2110
- """When not provided, the property is None and the httpx default (5s) is used."""
2111
- assert invalid_credential_connection.http_timeout_secs is None
2112
- # httpx's default Timeout(timeout=5.0) is wrapped in a Timeout object on the client.
2113
- assert invalid_credential_connection._client.timeout == httpx.Timeout(5.0)
2422
+ """When not provided, the property reports the effective default, not None."""
2423
+ assert invalid_credential_connection.http_timeout_secs == DEFAULT_HTTP_TIMEOUT_SECS
2424
+ assert invalid_credential_connection._client.timeout == httpx.Timeout(
2425
+ DEFAULT_HTTP_TIMEOUT_SECS
2426
+ )
2427
+
2428
+ def test_default_http_timeout_secs_is_ten_seconds(self):
2429
+ """Pin the exact default value so a silent change here doesn't slip by unnoticed."""
2430
+ assert DEFAULT_HTTP_TIMEOUT_SECS == 10.0
2114
2431
 
2115
2432
  @pytest.mark.parametrize("timeout_value", [0.5, 1, 10, 30.0, 120])
2116
2433
  def test_custom_timeout_via_constructor(
@@ -1,6 +1,5 @@
1
1
  import re
2
2
  import types
3
- import warnings
4
3
 
5
4
  import pytest
6
5
 
@@ -1673,63 +1672,6 @@ class TestMayHaveResults:
1673
1672
  assert mock_connection_cursor.may_have_results is True
1674
1673
 
1675
1674
 
1676
- @pytest.mark.unit
1677
- class TestSnapshotWarning:
1678
- """Tests for the early access warning emitted when creating snapshot mode cursors."""
1679
-
1680
- def test_snapshot_warning_emitted_once_per_connection(self, mock_connection_factory):
1681
- """Verify that snapshot mode warning is emitted exactly once per connection."""
1682
- mock_connection = mock_connection_factory(None, None)
1683
-
1684
- # First snapshot cursor should emit the warning
1685
- with pytest.warns(
1686
- UserWarning,
1687
- match="Snapshot queries on Confluent Cloud Flink SQL are currently in Early Access",
1688
- ):
1689
- cursor1 = mock_connection.cursor(mode=ExecutionMode.SNAPSHOT)
1690
-
1691
- # Flag should now be set
1692
- assert mock_connection._snapshot_warning_issued is True
1693
-
1694
- # Second snapshot cursor should NOT emit warning
1695
- with warnings.catch_warnings(record=True) as warning_list:
1696
- warnings.simplefilter("always")
1697
- cursor2 = mock_connection.cursor(mode=ExecutionMode.SNAPSHOT)
1698
-
1699
- # Filter to only UserWarnings about snapshot
1700
- snapshot_warnings = [
1701
- w for w in warning_list
1702
- if issubclass(w.category, UserWarning)
1703
- and "Snapshot queries" in str(w.message)
1704
- ]
1705
- assert len(snapshot_warnings) == 0
1706
-
1707
- cursor1.close()
1708
- cursor2.close()
1709
-
1710
- def test_snapshot_warning_not_emitted_for_streaming_query_cursor(self, mock_connection_factory):
1711
- """Verify that warning is not emitted for streaming query cursors."""
1712
- mock_connection = mock_connection_factory(None, None)
1713
-
1714
- # Streaming query cursor should not emit warning
1715
- with warnings.catch_warnings(record=True) as warning_list:
1716
- warnings.simplefilter("always")
1717
- cursor = mock_connection.cursor(mode=ExecutionMode.STREAMING_QUERY)
1718
-
1719
- # Filter to only UserWarnings about snapshot
1720
- snapshot_warnings = [
1721
- w for w in warning_list
1722
- if issubclass(w.category, UserWarning)
1723
- and "Snapshot queries" in str(w.message)
1724
- ]
1725
- assert len(snapshot_warnings) == 0
1726
-
1727
- # Flag should still be False
1728
- assert mock_connection._snapshot_warning_issued is False
1729
-
1730
- cursor.close()
1731
-
1732
-
1733
1675
  @pytest.mark.unit
1734
1676
  class TestComputePoolIdParameter:
1735
1677
  """Test the compute_pool_id parameter for Cursor.execute() and related methods."""
@@ -0,0 +1,155 @@
1
+ import logging
2
+ from unittest.mock import Mock
3
+
4
+ import httpx
5
+ import pytest
6
+
7
+ from confluent_sql import retry
8
+ from confluent_sql.retry import (
9
+ DEFAULT_RETRYABLE_EXCEPTIONS,
10
+ DEFAULT_RETRYABLE_STATUS_CODES,
11
+ call_with_retries,
12
+ )
13
+
14
+
15
+ @pytest.fixture()
16
+ def no_sleep(mocker):
17
+ """Patch away real sleeping so retry tests run instantly, while still letting call counts and
18
+ sleep durations be asserted."""
19
+ return mocker.patch.object(retry.time, "sleep")
20
+
21
+
22
+ @pytest.fixture()
23
+ def no_jitter(mocker):
24
+ """Pin jitter to 1.0 so sleep durations are deterministic."""
25
+ return mocker.patch.object(retry.random, "uniform", return_value=1.0)
26
+
27
+
28
+ @pytest.mark.unit
29
+ class TestCallWithRetries:
30
+ def test_succeeds_without_retrying(self, no_sleep):
31
+ func = Mock(return_value="ok")
32
+
33
+ result = call_with_retries(func, "a", b="c")
34
+
35
+ assert result == "ok"
36
+ func.assert_called_once_with("a", b="c")
37
+ no_sleep.assert_not_called()
38
+
39
+ def test_retries_and_eventually_succeeds(self, no_sleep, no_jitter):
40
+ func = Mock(
41
+ side_effect=[
42
+ httpx.ReadError("boom"),
43
+ httpx.ReadError("boom again"),
44
+ "ok",
45
+ ]
46
+ )
47
+
48
+ result = call_with_retries(func, "a", b="c")
49
+
50
+ assert result == "ok"
51
+ assert func.call_count == 3
52
+ for call in func.call_args_list:
53
+ assert call.args == ("a",)
54
+ assert call.kwargs == {"b": "c"}
55
+
56
+ def test_exhausts_retries_and_reraises_original_exception(self, no_sleep, no_jitter):
57
+ original = httpx.ReadError("boom")
58
+ func = Mock(side_effect=[original, httpx.ReadError("2"), httpx.ReadError("3"), original])
59
+
60
+ with pytest.raises(httpx.ReadError) as exc_info:
61
+ call_with_retries(func, max_retries=3)
62
+
63
+ assert exc_info.value is original
64
+ assert func.call_count == 4
65
+
66
+ def test_logs_one_info_record_per_retry_naming_attempt_number_and_exception_message(
67
+ self, no_sleep, no_jitter, caplog
68
+ ):
69
+ func = Mock(
70
+ side_effect=[
71
+ httpx.ReadError("Connection reset by peer"),
72
+ httpx.RemoteProtocolError("Server disconnected without sending a response"),
73
+ "ok",
74
+ ]
75
+ )
76
+
77
+ with caplog.at_level(logging.INFO, logger=retry.logger.name):
78
+ call_with_retries(func, max_retries=3)
79
+
80
+ info_records = [r for r in caplog.records if r.levelno == logging.INFO]
81
+ assert len(info_records) == 2
82
+ assert "attempt 1/3" in info_records[0].message
83
+ assert '"Connection reset by peer"' in info_records[0].message
84
+ assert "attempt 2/3" in info_records[1].message
85
+ assert '"Server disconnected without sending a response"' in info_records[1].message
86
+
87
+ def test_non_retryable_exception_propagates_without_retry(self, no_sleep):
88
+ func = Mock(side_effect=ValueError("not retryable"))
89
+
90
+ with pytest.raises(ValueError, match="not retryable"):
91
+ call_with_retries(func)
92
+
93
+ func.assert_called_once()
94
+ no_sleep.assert_not_called()
95
+
96
+ def test_max_retries_zero_means_single_call_and_immediate_reraise(self, no_sleep):
97
+ func = Mock(side_effect=httpx.ReadError("boom"))
98
+
99
+ with pytest.raises(httpx.ReadError):
100
+ call_with_retries(func, max_retries=0)
101
+
102
+ func.assert_called_once()
103
+ no_sleep.assert_not_called()
104
+
105
+ def test_custom_exceptions_tuple_overrides_default_set(self, no_sleep, no_jitter):
106
+ func = Mock(side_effect=[ValueError("custom"), "ok"])
107
+
108
+ result = call_with_retries(func, exceptions=(ValueError,))
109
+
110
+ assert result == "ok"
111
+ assert func.call_count == 2
112
+
113
+ def test_backoff_grows_and_is_jittered(self, mocker, no_sleep):
114
+ uniform = mocker.patch.object(retry.random, "uniform", side_effect=[1.0, 0.75, 1.25])
115
+ func = Mock(
116
+ side_effect=[
117
+ httpx.ReadError("1"),
118
+ httpx.ReadError("2"),
119
+ httpx.ReadError("3"),
120
+ "ok",
121
+ ]
122
+ )
123
+
124
+ call_with_retries(func, max_retries=3)
125
+
126
+ slept = [call.args[0] for call in no_sleep.call_args_list]
127
+ assert len(slept) == 3
128
+ # Each successive base delay grows; jitter draws (1.0, 0.75, 1.25) scale it further.
129
+ assert slept[0] < slept[1] < slept[2]
130
+ assert uniform.call_count == 3
131
+
132
+ def test_default_retryable_exceptions(self):
133
+ assert httpx.NetworkError in DEFAULT_RETRYABLE_EXCEPTIONS
134
+ assert httpx.RemoteProtocolError in DEFAULT_RETRYABLE_EXCEPTIONS
135
+ assert httpx.TimeoutException not in DEFAULT_RETRYABLE_EXCEPTIONS
136
+
137
+ def test_default_exceptions_do_not_retry_timeout(self, no_sleep):
138
+ """Tuple membership alone (test_default_retryable_exceptions above) doesn't prove
139
+ httpx.TimeoutException is never retried -- it would still be caught if it were a
140
+ subclass of one of the default exceptions. Assert the actual behavior instead: a timeout
141
+ propagates on the first call, with no retry and no sleep."""
142
+ func = Mock(side_effect=httpx.TimeoutException("timed out"))
143
+
144
+ with pytest.raises(httpx.TimeoutException):
145
+ call_with_retries(func)
146
+
147
+ func.assert_called_once()
148
+ no_sleep.assert_not_called()
149
+
150
+ def test_default_retryable_status_codes(self):
151
+ assert frozenset({429, 500, 502, 503, 504}) == DEFAULT_RETRYABLE_STATUS_CODES
152
+ assert 408 not in DEFAULT_RETRYABLE_STATUS_CODES
153
+ assert 200 not in DEFAULT_RETRYABLE_STATUS_CODES
154
+ assert 404 not in DEFAULT_RETRYABLE_STATUS_CODES
155
+ assert 400 not in DEFAULT_RETRYABLE_STATUS_CODES
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes