snowflake-sql-api 0.1.4__tar.gz → 0.1.5__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.
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/PKG-INFO +29 -1
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/README.md +28 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/snowflake_sql_api/_version.py +2 -2
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/snowflake_sql_api/aclient.py +58 -9
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/snowflake_sql_api/client.py +69 -8
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/snowflake_sql_api/exceptions.py +2 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/snowflake_sql_api/retry.py +5 -5
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/snowflake_sql_api/transport.py +12 -5
- snowflake_sql_api-0.1.5/tests/smoke/test_submissions.py +300 -0
- snowflake_sql_api-0.1.5/tests/test_submissions.py +519 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/.gitignore +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/CONTRIBUTING.md +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/LICENSE +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/pyproject.toml +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/snowflake_sql_api/__init__.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/snowflake_sql_api/auth.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/snowflake_sql_api/batches.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/snowflake_sql_api/bindings.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/snowflake_sql_api/cli.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/snowflake_sql_api/escaping.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/snowflake_sql_api/exports.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/snowflake_sql_api/pagination.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/snowflake_sql_api/py.typed +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/snowflake_sql_api/row_mapping.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/snowflake_sql_api/testing.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/snowflake_sql_api/types.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/__init__.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/conftest.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/smoke/__init__.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/smoke/test_smoke.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/streaming_support.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/support.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/test_aclient.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/test_auth.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/test_batch_writes.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/test_bindings.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/test_cli.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/test_client.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/test_escaping.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/test_exports.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/test_import_footprint.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/test_package.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/test_regression.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/test_retry.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/test_streaming.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/test_testing.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/test_transport.py +0 -0
- {snowflake_sql_api-0.1.4 → snowflake_sql_api-0.1.5}/tests/test_types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: snowflake-sql-api
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Lightweight, pure-Python client for Snowflake's SQL API v2
|
|
5
5
|
Project-URL: Homepage, https://github.com/hampsterx/snowflake-sql-api
|
|
6
6
|
Project-URL: Repository, https://github.com/hampsterx/snowflake-sql-api
|
|
@@ -95,6 +95,7 @@ stays small and cold-starts quickly. Core dependencies: `httpx`, `PyJWT`,
|
|
|
95
95
|
- Multi-partition result handling for large result sets
|
|
96
96
|
- Lazy row streams and bounded row batches, with CSV and JSONL exports
|
|
97
97
|
- Query helpers, DML, batch insert, async statement submission
|
|
98
|
+
- Caller-owned submission UUIDs and explicit replay across worker restarts
|
|
98
99
|
- A small CLI for ad-hoc queries
|
|
99
100
|
- Optional pandas (`[pandas]`) and typed-row (`[pydantic]`) helpers, kept out of
|
|
100
101
|
the default install
|
|
@@ -174,6 +175,33 @@ do not resume with the same advanced iterator. The async equivalent is awaited a
|
|
|
174
175
|
accepts the same synchronous iterable, whose production can block the event loop.
|
|
175
176
|
See [batch writes](docs/batch-writes.md) for limits and recovery semantics.
|
|
176
177
|
|
|
178
|
+
## Replayable submissions
|
|
179
|
+
|
|
180
|
+
For a queued job, allocate a UUID and persist it with the SQL, typed inputs and
|
|
181
|
+
explicit execution context **before** calling `submit()`. Pass that saved ID:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
handle = client.submit(job["sql"], job["params"], request_id=job["request_id"])
|
|
185
|
+
# Persist handle.statement_handle when it reaches the worker.
|
|
186
|
+
|
|
187
|
+
# After a restart with no saved statement handle, rebuild identical inputs:
|
|
188
|
+
handle = client.resubmit(job["sql"], job["params"], request_id=job["request_id"])
|
|
189
|
+
rows = handle.result() # Only then mark the durable job complete.
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`resubmit()` sends the same ID with `retry=true` on its first HTTP attempt.
|
|
193
|
+
A saved statement handle can instead be attached with `QueryHandle(client, saved_handle)`
|
|
194
|
+
and polled without another submission. Both clients expose `handle.request_id`, and
|
|
195
|
+
submission errors in the `SnowflakeError` family carry the allocated ID. Async
|
|
196
|
+
submission, resubmission and result methods are awaited.
|
|
197
|
+
|
|
198
|
+
The application owns durable storage, identical payload reconstruction, UUID
|
|
199
|
+
uniqueness and reconciliation. Retained Snowflake history governs recovery; there
|
|
200
|
+
is no unlimited replay window or exactly-once processing guarantee. Live restart
|
|
201
|
+
verification requires the opt-in disposable-resource probes. See
|
|
202
|
+
[replayable submissions](docs/submissions.md) for the complete persistence recipe,
|
|
203
|
+
limits and verification status.
|
|
204
|
+
|
|
177
205
|
## Prior art and alternatives
|
|
178
206
|
|
|
179
207
|
`snowflake-sql-api` is not the first REST-based Snowflake client. If it does not
|
|
@@ -31,6 +31,7 @@ stays small and cold-starts quickly. Core dependencies: `httpx`, `PyJWT`,
|
|
|
31
31
|
- Multi-partition result handling for large result sets
|
|
32
32
|
- Lazy row streams and bounded row batches, with CSV and JSONL exports
|
|
33
33
|
- Query helpers, DML, batch insert, async statement submission
|
|
34
|
+
- Caller-owned submission UUIDs and explicit replay across worker restarts
|
|
34
35
|
- A small CLI for ad-hoc queries
|
|
35
36
|
- Optional pandas (`[pandas]`) and typed-row (`[pydantic]`) helpers, kept out of
|
|
36
37
|
the default install
|
|
@@ -110,6 +111,33 @@ do not resume with the same advanced iterator. The async equivalent is awaited a
|
|
|
110
111
|
accepts the same synchronous iterable, whose production can block the event loop.
|
|
111
112
|
See [batch writes](docs/batch-writes.md) for limits and recovery semantics.
|
|
112
113
|
|
|
114
|
+
## Replayable submissions
|
|
115
|
+
|
|
116
|
+
For a queued job, allocate a UUID and persist it with the SQL, typed inputs and
|
|
117
|
+
explicit execution context **before** calling `submit()`. Pass that saved ID:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
handle = client.submit(job["sql"], job["params"], request_id=job["request_id"])
|
|
121
|
+
# Persist handle.statement_handle when it reaches the worker.
|
|
122
|
+
|
|
123
|
+
# After a restart with no saved statement handle, rebuild identical inputs:
|
|
124
|
+
handle = client.resubmit(job["sql"], job["params"], request_id=job["request_id"])
|
|
125
|
+
rows = handle.result() # Only then mark the durable job complete.
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`resubmit()` sends the same ID with `retry=true` on its first HTTP attempt.
|
|
129
|
+
A saved statement handle can instead be attached with `QueryHandle(client, saved_handle)`
|
|
130
|
+
and polled without another submission. Both clients expose `handle.request_id`, and
|
|
131
|
+
submission errors in the `SnowflakeError` family carry the allocated ID. Async
|
|
132
|
+
submission, resubmission and result methods are awaited.
|
|
133
|
+
|
|
134
|
+
The application owns durable storage, identical payload reconstruction, UUID
|
|
135
|
+
uniqueness and reconciliation. Retained Snowflake history governs recovery; there
|
|
136
|
+
is no unlimited replay window or exactly-once processing guarantee. Live restart
|
|
137
|
+
verification requires the opt-in disposable-resource probes. See
|
|
138
|
+
[replayable submissions](docs/submissions.md) for the complete persistence recipe,
|
|
139
|
+
limits and verification status.
|
|
140
|
+
|
|
113
141
|
## Prior art and alternatives
|
|
114
142
|
|
|
115
143
|
`snowflake-sql-api` is not the first REST-based Snowflake client. If it does not
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.1.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1,
|
|
21
|
+
__version__ = version = '0.1.5'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 5)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -30,11 +30,12 @@ import httpx
|
|
|
30
30
|
from .auth import KeypairAuthenticator, account_hostname
|
|
31
31
|
from .batches import BatchWriteResult, _BatchExecution, _BatchPlanner
|
|
32
32
|
from .bindings import to_bindings
|
|
33
|
-
from .client import DEFAULT_POLL_TIMEOUT, _columns_from, _rows_affected
|
|
33
|
+
from .client import DEFAULT_POLL_TIMEOUT, _columns_from, _rows_affected, _submission_id
|
|
34
34
|
from .escaping import quote_identifier, quote_name
|
|
35
35
|
from .exceptions import (
|
|
36
36
|
ResultNotReady,
|
|
37
37
|
SnowflakeConfigError,
|
|
38
|
+
SnowflakeError,
|
|
38
39
|
SnowflakeRequestError,
|
|
39
40
|
SnowflakeTimeoutError,
|
|
40
41
|
)
|
|
@@ -61,9 +62,16 @@ OnQuery = Callable[[str, "Params"], None]
|
|
|
61
62
|
class AsyncQueryHandle:
|
|
62
63
|
"""Async handle to a submitted statement (see :class:`~snowflake_sql_api.client.QueryHandle`)."""
|
|
63
64
|
|
|
64
|
-
def __init__(
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
client: AsyncSnowflakeClient,
|
|
68
|
+
statement_handle: str,
|
|
69
|
+
*,
|
|
70
|
+
request_id: Optional[str] = None,
|
|
71
|
+
) -> None:
|
|
65
72
|
self._client = client
|
|
66
73
|
self.statement_handle = statement_handle
|
|
74
|
+
self.request_id = request_id
|
|
67
75
|
|
|
68
76
|
async def result(
|
|
69
77
|
self, *, poll: bool = True, timeout: Optional[float] = None
|
|
@@ -436,13 +444,54 @@ class AsyncSnowflakeClient:
|
|
|
436
444
|
del batch
|
|
437
445
|
return planner.result()
|
|
438
446
|
|
|
439
|
-
async def submit(
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
447
|
+
async def submit(
|
|
448
|
+
self,
|
|
449
|
+
sql: str,
|
|
450
|
+
params: Params = None,
|
|
451
|
+
*,
|
|
452
|
+
request_id: str | uuid.UUID | None = None,
|
|
453
|
+
) -> AsyncQueryHandle:
|
|
454
|
+
"""Submit once with a caller-owned UUID, or generate a fresh UUID4.
|
|
455
|
+
|
|
456
|
+
Persist the ID and identical inputs before calling for restart recovery.
|
|
457
|
+
Use :meth:`resubmit` once a previous attempt might have reached Snowflake.
|
|
458
|
+
"""
|
|
459
|
+
return await self._submit_handle(
|
|
460
|
+
sql, params, request_id=request_id, retry=False
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
async def resubmit(
|
|
464
|
+
self, sql: str, params: Params = None, *, request_id: str | uuid.UUID
|
|
465
|
+
) -> AsyncQueryHandle:
|
|
466
|
+
"""Replay identical saved inputs with the same UUID and ``retry=true``.
|
|
467
|
+
|
|
468
|
+
The caller owns durable storage, payload consistency and reconciliation
|
|
469
|
+
when Snowflake's retained history cannot resolve an uncertain outcome.
|
|
470
|
+
"""
|
|
471
|
+
return await self._submit_handle(sql, params, request_id=request_id, retry=True)
|
|
472
|
+
|
|
473
|
+
async def _submit_handle(
|
|
474
|
+
self,
|
|
475
|
+
sql: str,
|
|
476
|
+
params: Params,
|
|
477
|
+
*,
|
|
478
|
+
request_id: str | uuid.UUID | None,
|
|
479
|
+
retry: bool,
|
|
480
|
+
) -> AsyncQueryHandle:
|
|
481
|
+
canonical_id = _submission_id(request_id, retry=retry)
|
|
482
|
+
self._notify(sql, params)
|
|
483
|
+
try:
|
|
484
|
+
payload = self._build_payload(sql, params)
|
|
485
|
+
response = await self._transport.submit(
|
|
486
|
+
payload, request_id=canonical_id, async_exec=True, retry=retry
|
|
487
|
+
)
|
|
488
|
+
handle = response.statement_handle
|
|
489
|
+
if handle is None:
|
|
490
|
+
raise SnowflakeRequestError("async submit returned no statement handle")
|
|
491
|
+
return AsyncQueryHandle(self, handle, request_id=canonical_id)
|
|
492
|
+
except SnowflakeError as exc:
|
|
493
|
+
exc.request_id = canonical_id
|
|
494
|
+
raise
|
|
446
495
|
|
|
447
496
|
# -- lifecycle --------------------------------------------------------
|
|
448
497
|
|
|
@@ -35,6 +35,7 @@ from .escaping import quote_identifier, quote_name
|
|
|
35
35
|
from .exceptions import (
|
|
36
36
|
ResultNotReady,
|
|
37
37
|
SnowflakeConfigError,
|
|
38
|
+
SnowflakeError,
|
|
38
39
|
SnowflakeRequestError,
|
|
39
40
|
SnowflakeTimeoutError,
|
|
40
41
|
)
|
|
@@ -61,6 +62,20 @@ OnQuery = Callable[[str, "Params"], None]
|
|
|
61
62
|
DEFAULT_POLL_TIMEOUT = 600.0
|
|
62
63
|
|
|
63
64
|
|
|
65
|
+
def _submission_id(request_id: str | uuid.UUID | None, *, retry: bool) -> str:
|
|
66
|
+
"""Allocate or canonicalize an identity before hooks, binding or HTTP."""
|
|
67
|
+
if request_id is None and not retry:
|
|
68
|
+
return str(uuid.uuid4())
|
|
69
|
+
if isinstance(request_id, uuid.UUID):
|
|
70
|
+
return str(request_id)
|
|
71
|
+
if not isinstance(request_id, str):
|
|
72
|
+
raise SnowflakeConfigError("request_id must be a UUID string or UUID object")
|
|
73
|
+
try:
|
|
74
|
+
return str(uuid.UUID(request_id))
|
|
75
|
+
except ValueError as exc:
|
|
76
|
+
raise SnowflakeConfigError("request_id must be a valid UUID") from exc
|
|
77
|
+
|
|
78
|
+
|
|
64
79
|
def _affected_count(value: Any, strict: bool) -> int:
|
|
65
80
|
if strict and (isinstance(value, bool) or not isinstance(value, (int, str))):
|
|
66
81
|
raise ValueError("rows-affected count must be an integer")
|
|
@@ -108,9 +123,16 @@ class QueryHandle:
|
|
|
108
123
|
(regression bug #3).
|
|
109
124
|
"""
|
|
110
125
|
|
|
111
|
-
def __init__(
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
client: SnowflakeClient,
|
|
129
|
+
statement_handle: str,
|
|
130
|
+
*,
|
|
131
|
+
request_id: Optional[str] = None,
|
|
132
|
+
) -> None:
|
|
112
133
|
self._client = client
|
|
113
134
|
self.statement_handle = statement_handle
|
|
135
|
+
self.request_id = request_id
|
|
114
136
|
|
|
115
137
|
def result(
|
|
116
138
|
self, *, poll: bool = True, timeout: Optional[float] = None
|
|
@@ -483,13 +505,52 @@ class SnowflakeClient:
|
|
|
483
505
|
|
|
484
506
|
# -- async submit -----------------------------------------------------
|
|
485
507
|
|
|
486
|
-
def submit(
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
508
|
+
def submit(
|
|
509
|
+
self,
|
|
510
|
+
sql: str,
|
|
511
|
+
params: Params = None,
|
|
512
|
+
*,
|
|
513
|
+
request_id: str | uuid.UUID | None = None,
|
|
514
|
+
) -> QueryHandle:
|
|
515
|
+
"""Submit once with a caller-owned UUID, or generate a fresh UUID4.
|
|
516
|
+
|
|
517
|
+
Persist the ID and identical inputs before calling for restart recovery.
|
|
518
|
+
Use :meth:`resubmit` once a previous attempt might have reached Snowflake.
|
|
519
|
+
"""
|
|
520
|
+
return self._submit_handle(sql, params, request_id=request_id, retry=False)
|
|
521
|
+
|
|
522
|
+
def resubmit(
|
|
523
|
+
self, sql: str, params: Params = None, *, request_id: str | uuid.UUID
|
|
524
|
+
) -> QueryHandle:
|
|
525
|
+
"""Replay identical saved inputs with the same UUID and ``retry=true``.
|
|
526
|
+
|
|
527
|
+
The caller owns durable storage, payload consistency and reconciliation
|
|
528
|
+
when Snowflake's retained history cannot resolve an uncertain outcome.
|
|
529
|
+
"""
|
|
530
|
+
return self._submit_handle(sql, params, request_id=request_id, retry=True)
|
|
531
|
+
|
|
532
|
+
def _submit_handle(
|
|
533
|
+
self,
|
|
534
|
+
sql: str,
|
|
535
|
+
params: Params,
|
|
536
|
+
*,
|
|
537
|
+
request_id: str | uuid.UUID | None,
|
|
538
|
+
retry: bool,
|
|
539
|
+
) -> QueryHandle:
|
|
540
|
+
canonical_id = _submission_id(request_id, retry=retry)
|
|
541
|
+
self._notify(sql, params)
|
|
542
|
+
try:
|
|
543
|
+
payload = self._build_payload(sql, params)
|
|
544
|
+
response = self._transport.submit(
|
|
545
|
+
payload, request_id=canonical_id, async_exec=True, retry=retry
|
|
546
|
+
)
|
|
547
|
+
handle = response.statement_handle
|
|
548
|
+
if handle is None:
|
|
549
|
+
raise SnowflakeRequestError("async submit returned no statement handle")
|
|
550
|
+
return QueryHandle(self, handle, request_id=canonical_id)
|
|
551
|
+
except SnowflakeError as exc:
|
|
552
|
+
exc.request_id = canonical_id
|
|
553
|
+
raise
|
|
493
554
|
|
|
494
555
|
# -- lifecycle --------------------------------------------------------
|
|
495
556
|
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Defines which conditions are retryable (connect/read timeout, HTTP 429, 5xx,
|
|
4
4
|
status-poll, partition fetch) and the backoff schedule. Retries reuse the
|
|
5
|
-
statement ``requestId``
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
statement ``requestId`` and add ``retry=true`` after the initial attempt.
|
|
6
|
+
Snowflake uses retained statement history to resolve an identical submission;
|
|
7
|
+
this is not an unlimited deduplication or exactly-once guarantee. The transport
|
|
8
|
+
owns this wiring.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
@@ -32,7 +32,7 @@ class RetryPolicy:
|
|
|
32
32
|
multiplier: float = 2.0
|
|
33
33
|
jitter: bool = True
|
|
34
34
|
retry_statuses: FrozenSet[int] = field(
|
|
35
|
-
#
|
|
35
|
+
# Submissions retain their requestId and use retry=true after a timeout.
|
|
36
36
|
default_factory=lambda: frozenset({408, 429, 500, 502, 503, 504})
|
|
37
37
|
)
|
|
38
38
|
|
|
@@ -4,8 +4,9 @@ Owns the actual ``httpx`` calls (sync and async), status-code interpretation
|
|
|
4
4
|
(200 success, 202 long-running/async, 422 execution failure, 401 auth,
|
|
5
5
|
403 forbidden, 429/5xx retryable), the retry loop, and ``requestId`` reuse for
|
|
6
6
|
idempotent retries. A retried submit re-POSTs the identical body with the same
|
|
7
|
-
``requestId`` and ``retry=true
|
|
8
|
-
|
|
7
|
+
``requestId`` and ``retry=true``. Snowflake resolves identical submissions
|
|
8
|
+
through retained statement history, with no unlimited replay window or
|
|
9
|
+
application-wide exactly-once guarantee.
|
|
9
10
|
|
|
10
11
|
Higher layers (``client``/``aclient``) talk to the transport, not to ``httpx``.
|
|
11
12
|
"""
|
|
@@ -156,8 +157,12 @@ class _TransportBase:
|
|
|
156
157
|
"User-Agent": self._user_agent,
|
|
157
158
|
}
|
|
158
159
|
|
|
159
|
-
def _submit_params(
|
|
160
|
+
def _submit_params(
|
|
161
|
+
self, request_id: str, async_exec: bool, retry: bool = False
|
|
162
|
+
) -> Dict[str, str]:
|
|
160
163
|
params = {"requestId": request_id}
|
|
164
|
+
if retry:
|
|
165
|
+
params["retry"] = "true"
|
|
161
166
|
if async_exec:
|
|
162
167
|
params["async"] = "true"
|
|
163
168
|
return params
|
|
@@ -196,13 +201,14 @@ class Transport(_TransportBase):
|
|
|
196
201
|
*,
|
|
197
202
|
request_id: str,
|
|
198
203
|
async_exec: bool = False,
|
|
204
|
+
retry: bool = False,
|
|
199
205
|
content: Optional[bytes] = None,
|
|
200
206
|
) -> StatementResponse:
|
|
201
207
|
"""POST a statement, retrying idempotently on transient failure."""
|
|
202
208
|
return self._send(
|
|
203
209
|
"POST",
|
|
204
210
|
STATEMENTS_PATH,
|
|
205
|
-
params=self._submit_params(request_id, async_exec),
|
|
211
|
+
params=self._submit_params(request_id, async_exec, retry),
|
|
206
212
|
json=payload if content is None else None,
|
|
207
213
|
content=content,
|
|
208
214
|
is_submit=True,
|
|
@@ -308,12 +314,13 @@ class AsyncTransport(_TransportBase):
|
|
|
308
314
|
*,
|
|
309
315
|
request_id: str,
|
|
310
316
|
async_exec: bool = False,
|
|
317
|
+
retry: bool = False,
|
|
311
318
|
content: Optional[bytes] = None,
|
|
312
319
|
) -> StatementResponse:
|
|
313
320
|
return await self._send(
|
|
314
321
|
"POST",
|
|
315
322
|
STATEMENTS_PATH,
|
|
316
|
-
params=self._submit_params(request_id, async_exec),
|
|
323
|
+
params=self._submit_params(request_id, async_exec, retry),
|
|
317
324
|
json=payload if content is None else None,
|
|
318
325
|
content=content,
|
|
319
326
|
is_submit=True,
|