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.
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/ARCHITECTURE.md +1 -1
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/CHANGELOG.md +14 -0
- confluent_sql-0.4.2/CLAUDE.md +29 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/DBAPI_EXTENSIONS.md +0 -2
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/PKG-INFO +2 -2
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/README.md +1 -1
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/STREAMING.md +0 -2
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/pyproject.toml +1 -1
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/connection.py +85 -53
- confluent_sql-0.4.2/src/confluent_sql/retry.py +82 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/conftest.py +2 -1
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/integration/test_connection.py +44 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/conftest.py +0 -1
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_connection_unit.py +322 -5
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_cursor_unit.py +0 -58
- confluent_sql-0.4.2/tests/unit/test_retry_unit.py +155 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/.github/CODEOWNERS +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/.gitignore +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/.semaphore/publish_to_pypi.yml +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/.semaphore/semaphore.yml +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/LICENSE.txt +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/Makefile +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/TYPES.md +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/examples/errors.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/examples/simple_append_only_streaming_query_example.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/examples/snapshot_mode_tuple_cursor_simple_example.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/service.yml +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/__init__.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/__version__.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/changelog_compressor.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/cursor.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/exceptions.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/execution_mode.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/polling.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/result_readers.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/statement.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/src/confluent_sql/types.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/__init__.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/integration/conftest.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/integration/test_cursor.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/integration/test_fetch.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_changelog_compressor_unit.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_changelog_unit.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_connection_unit_properties.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_execution_mode_unit.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_polling_unit.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_result_readers_unit.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_statement_unit.py +0 -0
- {confluent_sql-0.4.0 → confluent_sql-0.4.2}/tests/unit/test_types_unit.py +0 -0
- {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):**
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
@@ -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
|
|
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.
|
|
150
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
http_timeout_secs, (
|
|
338
|
-
)
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
1055
|
+
def http_timeout_secs(self) -> float:
|
|
1061
1056
|
"""
|
|
1062
|
-
Get the
|
|
1063
|
-
|
|
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.
|
|
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.
|
|
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
|
|
1406
|
-
|
|
1407
|
-
|
|
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
|
-
|
|
1410
|
-
response = self.
|
|
1411
|
-
|
|
1412
|
-
|
|
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
|
|
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
|
|
2419
|
+
def test_default_reports_confluent_sql_default(
|
|
2108
2420
|
self, invalid_credential_connection: Connection
|
|
2109
2421
|
):
|
|
2110
|
-
"""When not provided, the property
|
|
2111
|
-
assert invalid_credential_connection.http_timeout_secs
|
|
2112
|
-
|
|
2113
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
{confluent_sql-0.4.0 → confluent_sql-0.4.2}/examples/simple_append_only_streaming_query_example.py
RENAMED
|
File without changes
|
{confluent_sql-0.4.0 → confluent_sql-0.4.2}/examples/snapshot_mode_tuple_cursor_simple_example.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|