snowflake-sandbox-python 0.2.1a1__py3-none-any.whl
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/cli_sandbox/__init__.py +13 -0
- snowflake/cli_sandbox/_adapter.py +170 -0
- snowflake/cli_sandbox/_common.py +77 -0
- snowflake/cli_sandbox/_egress_flags.py +121 -0
- snowflake/cli_sandbox/_get_command.py +109 -0
- snowflake/cli_sandbox/_run_command.py +1091 -0
- snowflake/cli_sandbox/_shell_command.py +666 -0
- snowflake/cli_sandbox/_upload_plan.py +187 -0
- snowflake/cli_sandbox/commands.py +556 -0
- snowflake/cli_sandbox/plugin_spec.py +28 -0
- snowflake/cli_sandbox/py.typed +0 -0
- snowflake/sandbox/__init__.py +317 -0
- snowflake/sandbox/__main__.py +225 -0
- snowflake/sandbox/_ansi.py +206 -0
- snowflake/sandbox/_args.py +208 -0
- snowflake/sandbox/_assemble.py +256 -0
- snowflake/sandbox/_bundle.py +240 -0
- snowflake/sandbox/_connection_resolve.py +328 -0
- snowflake/sandbox/_deploy_spec.py +56 -0
- snowflake/sandbox/_diagnostics.py +501 -0
- snowflake/sandbox/_env.py +143 -0
- snowflake/sandbox/_files_mixin.py +280 -0
- snowflake/sandbox/_fs_ops.py +304 -0
- snowflake/sandbox/_globs.py +176 -0
- snowflake/sandbox/_hosts.py +110 -0
- snowflake/sandbox/_mcp_discovery.py +288 -0
- snowflake/sandbox/_mcp_status.py +183 -0
- snowflake/sandbox/_retry.py +94 -0
- snowflake/sandbox/_runtime/__init__.py +42 -0
- snowflake/sandbox/_runtime/_fs_helper.py +93 -0
- snowflake/sandbox/_runtime/_job_runner.py +111 -0
- snowflake/sandbox/_runtime/_protocol.py +53 -0
- snowflake/sandbox/_runtime/_shims.py +267 -0
- snowflake/sandbox/_sandbox_state.py +303 -0
- snowflake/sandbox/_session_registry.py +222 -0
- snowflake/sandbox/_sse.py +160 -0
- snowflake/sandbox/_stage.py +270 -0
- snowflake/sandbox/_sync_files_mixin.py +272 -0
- snowflake/sandbox/_sync_fs_ops.py +185 -0
- snowflake/sandbox/_sync_transport.py +737 -0
- snowflake/sandbox/_sync_watch.py +99 -0
- snowflake/sandbox/_transport.py +1366 -0
- snowflake/sandbox/_transport_errors.py +270 -0
- snowflake/sandbox/_upload_plan.py +497 -0
- snowflake/sandbox/_version.py +37 -0
- snowflake/sandbox/_watch.py +164 -0
- snowflake/sandbox/_wire.py +348 -0
- snowflake/sandbox/app.py +256 -0
- snowflake/sandbox/client.py +2356 -0
- snowflake/sandbox/config.py +1133 -0
- snowflake/sandbox/connect.py +288 -0
- snowflake/sandbox/deploy.py +499 -0
- snowflake/sandbox/egress.py +388 -0
- snowflake/sandbox/exceptions.py +253 -0
- snowflake/sandbox/exec_stream.py +264 -0
- snowflake/sandbox/files.py +547 -0
- snowflake/sandbox/function.py +567 -0
- snowflake/sandbox/image.py +46 -0
- snowflake/sandbox/jobs.py +649 -0
- snowflake/sandbox/lifecycle.py +67 -0
- snowflake/sandbox/log_stream.py +219 -0
- snowflake/sandbox/mcp.py +480 -0
- snowflake/sandbox/mount.py +161 -0
- snowflake/sandbox/py.typed +0 -0
- snowflake/sandbox/secret.py +244 -0
- snowflake/sandbox/session_app.py +244 -0
- snowflake/sandbox/shell.py +556 -0
- snowflake/sandbox/sync_client.py +2245 -0
- snowflake/sandbox/sync_exec_stream.py +238 -0
- snowflake/sandbox/sync_files.py +377 -0
- snowflake/sandbox/sync_log_stream.py +142 -0
- snowflake/sandbox/sync_shell.py +413 -0
- snowflake/sandbox/types.py +193 -0
- snowflake/sandbox/warm_session.py +700 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/METADATA +339 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/RECORD +80 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/WHEEL +5 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/entry_points.txt +2 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/licenses/LICENSE +202 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Error mapping and retry-timing helpers shared by the transport twins.
|
|
2
|
+
|
|
3
|
+
Loop-agnostic pieces used by both the async ``_transport`` and the sync
|
|
4
|
+
``_sync_transport``: the single terminal HTTP-status → ``SandboxError`` table, the
|
|
5
|
+
error-envelope unwrapping, the ``Retry-After`` / rate-limit backoff math, the
|
|
6
|
+
response classifiers that decide whether a failure is safe to retry, and the SSE
|
|
7
|
+
JSON-decode error. Extracted into a dependency-free leaf so both transports import
|
|
8
|
+
one copy, rather than the sync twin reaching into the async module for them.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import random
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from email.utils import parsedate_to_datetime
|
|
18
|
+
from typing import Any, Final
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from snowflake.sandbox._retry import (
|
|
23
|
+
DEFAULT_COLD_START_DELAYS_S,
|
|
24
|
+
compute_backoff,
|
|
25
|
+
)
|
|
26
|
+
from snowflake.sandbox.config import REAUTH_GS_CODES
|
|
27
|
+
from snowflake.sandbox.exceptions import (
|
|
28
|
+
SandboxAuthError,
|
|
29
|
+
SandboxConflictError,
|
|
30
|
+
SandboxError,
|
|
31
|
+
SandboxExecTimeoutError,
|
|
32
|
+
SandboxFileTooLargeError,
|
|
33
|
+
SandboxNotFoundError,
|
|
34
|
+
SandboxNotImplementedError,
|
|
35
|
+
SandboxRateLimitError,
|
|
36
|
+
SandboxTransportError,
|
|
37
|
+
SandboxValidationError,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# 409 (and 503) are conditionally retried for a non-idempotent request only when
|
|
41
|
+
# the response body says the work was rejected rather than attempted.
|
|
42
|
+
_NOT_READY_REASONS: Final = {"not_ready", "not-ready", "starting", "pending"}
|
|
43
|
+
|
|
44
|
+
# Every retry class shares one attempt budget so no path can retry forever. The
|
|
45
|
+
# streaming paths (`stream_sse`, `download`) used to retry a 429 without any cap
|
|
46
|
+
# -- measured at 153 requests in 8s -- because the Retry-After hint was honored
|
|
47
|
+
# unconditionally. They now stop at the same budget `request()` uses and surface
|
|
48
|
+
# `SandboxRateLimitError`.
|
|
49
|
+
_RATE_LIMIT_MAX_ATTEMPTS: Final = len(DEFAULT_COLD_START_DELAYS_S)
|
|
50
|
+
# Upper bound on a server-supplied Retry-After. A hostile or buggy header
|
|
51
|
+
# (`Retry-After: 1e309` -> sleep(inf) wedges the process; a multi-hour value
|
|
52
|
+
# hangs an interactive caller) must never park the SDK indefinitely.
|
|
53
|
+
_RETRY_AFTER_MAX_S: Final = 60.0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SandboxSSEDecodeError(SandboxError, ValueError):
|
|
57
|
+
"""An SSE frame's ``data`` was not valid JSON.
|
|
58
|
+
|
|
59
|
+
Subclasses ``SandboxError`` so ``except SandboxError`` catches it (the bare
|
|
60
|
+
``json.JSONDecodeError`` it replaces did not), and ``ValueError`` so existing
|
|
61
|
+
callers that already guarded ``json()`` with ``except (ValueError, TypeError)``
|
|
62
|
+
keep working -- ``json.JSONDecodeError`` was itself a ``ValueError``.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Cap on the raw-body fallback when the response is not the known error envelope,
|
|
67
|
+
# so a 5xx HTML error page or stack trace can never be dumped whole into an
|
|
68
|
+
# exception message a public caller sees.
|
|
69
|
+
_ERROR_DETAIL_MAX: Final = 200
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _error_detail(text: str) -> str:
|
|
73
|
+
"""The public-facing detail to interpolate into an exception message.
|
|
74
|
+
|
|
75
|
+
The server returns errors as a ``{"error": {"message": ...}}`` JSON envelope;
|
|
76
|
+
unwrap that to the human ``message`` so a caller (and any log line) sees
|
|
77
|
+
``container not found`` rather than the whole envelope. Only when the body is
|
|
78
|
+
*not* that envelope do we fall back to the raw text, truncated to
|
|
79
|
+
``_ERROR_DETAIL_MAX`` so an internal 5xx error page / stack trace is never
|
|
80
|
+
surfaced verbatim.
|
|
81
|
+
"""
|
|
82
|
+
stripped = text.strip()
|
|
83
|
+
if stripped:
|
|
84
|
+
try:
|
|
85
|
+
parsed: Any = json.loads(stripped)
|
|
86
|
+
except (ValueError, TypeError):
|
|
87
|
+
parsed = None
|
|
88
|
+
if isinstance(parsed, Mapping):
|
|
89
|
+
err = parsed.get("error")
|
|
90
|
+
if isinstance(err, Mapping):
|
|
91
|
+
message = err.get("message")
|
|
92
|
+
if isinstance(message, str) and message:
|
|
93
|
+
return message
|
|
94
|
+
return stripped[:_ERROR_DETAIL_MAX]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _status_to_error(status: int, text: str) -> SandboxError:
|
|
98
|
+
"""The single terminal status -> exception table for every path.
|
|
99
|
+
|
|
100
|
+
`request()`, `download`, and `stream_sse` previously each kept their own copy
|
|
101
|
+
and disagreed on 409 (``SandboxError("conflict")`` vs ``SandboxNotReadyError``),
|
|
102
|
+
on 413 (only download mapped it, so ``except SandboxFileTooLargeError`` around
|
|
103
|
+
an upload never fired), on 429, and on the unknown-status fallback
|
|
104
|
+
(``SandboxTransportError`` vs ``SandboxError``). Which exception a caller had to
|
|
105
|
+
catch depended on which internal helper routed the request. This is that one
|
|
106
|
+
table; all three paths raise from it.
|
|
107
|
+
|
|
108
|
+
The response body is passed through ``_error_detail``, which unwraps the
|
|
109
|
+
server's ``{"error": {"message": ...}}`` envelope to the human message and
|
|
110
|
+
truncates any non-envelope body -- so pass the *full* ``resp.text`` here, not a
|
|
111
|
+
pre-truncated slice.
|
|
112
|
+
|
|
113
|
+
A terminal 409 here is a genuine conflict, never a cold-start: the retry loop
|
|
114
|
+
consumes a "not ready" 409 before the request reaches this mapping, so a 409
|
|
115
|
+
that arrives here means a real conflict (a duplicate name, say), not "still
|
|
116
|
+
starting" -- which is why it maps to ``SandboxConflictError`` rather than
|
|
117
|
+
``SandboxNotReadyError`` (mapping it to "not ready" told a caller reading a
|
|
118
|
+
directory that the sandbox was starting, and invited an endless retry).
|
|
119
|
+
|
|
120
|
+
501 maps to ``SandboxNotImplementedError`` (a permanent "route not wired",
|
|
121
|
+
e.g. ``stage_put``/``stage_get``) rather than the generic 5xx
|
|
122
|
+
``SandboxTransportError`` -- so a caller does not read it as a retryable
|
|
123
|
+
transient failure; it must precede the ``5xx`` catch-all below.
|
|
124
|
+
"""
|
|
125
|
+
detail = _error_detail(text)
|
|
126
|
+
if status == 400:
|
|
127
|
+
return SandboxValidationError(f"invalid request: {detail}")
|
|
128
|
+
if status in (401, 403):
|
|
129
|
+
return SandboxAuthError(f"HTTP {status}: {detail}")
|
|
130
|
+
if status == 404:
|
|
131
|
+
return SandboxNotFoundError(f"not found: {detail}")
|
|
132
|
+
if status == 408:
|
|
133
|
+
return SandboxExecTimeoutError(f"exec timeout: {detail}")
|
|
134
|
+
if status == 409:
|
|
135
|
+
return SandboxConflictError(f"resource conflict: {detail}")
|
|
136
|
+
if status == 413:
|
|
137
|
+
return SandboxFileTooLargeError(f"file too large: {detail}")
|
|
138
|
+
if status == 429:
|
|
139
|
+
return SandboxRateLimitError(f"rate-limited: {detail}")
|
|
140
|
+
if status == 501:
|
|
141
|
+
return SandboxNotImplementedError(f"not implemented: {detail}")
|
|
142
|
+
if 500 <= status < 600:
|
|
143
|
+
return SandboxTransportError(f"HTTP {status}: {detail}")
|
|
144
|
+
return SandboxError(f"HTTP {status}: {detail}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _says_not_ready(resp: httpx.Response) -> bool:
|
|
148
|
+
"""True when the body identifies the response as "rejected, not attempted".
|
|
149
|
+
|
|
150
|
+
A ``409``/``503`` carrying one of the not-ready reasons is a definitive
|
|
151
|
+
statement that the request was refused before it ran, which makes a retry
|
|
152
|
+
safe even for a non-idempotent method. A bare ``503`` (an LB error page, an
|
|
153
|
+
empty body) proves nothing about whether the work happened.
|
|
154
|
+
"""
|
|
155
|
+
try:
|
|
156
|
+
body = resp.json()
|
|
157
|
+
except Exception:
|
|
158
|
+
return False
|
|
159
|
+
if not isinstance(body, dict):
|
|
160
|
+
return False
|
|
161
|
+
code = str(body.get("code") or body.get("reason") or body.get("error") or "")
|
|
162
|
+
return code.lower().replace("_", "-") in {r.replace("_", "-") for r in _NOT_READY_REASONS}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _is_cold_start_409(resp: httpx.Response) -> bool:
|
|
166
|
+
return resp.status_code == 409 and _says_not_ready(resp)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _is_reauth_response(resp: httpx.Response) -> bool:
|
|
170
|
+
"""True when a response means "re-authenticate", for EITHER error envelope.
|
|
171
|
+
|
|
172
|
+
An expired/invalid credential surfaces on this REST surface two ways, and the
|
|
173
|
+
reactive re-mint must catch both:
|
|
174
|
+
- a proper HTTP **401/403** (the OAuth ``Bearer`` path, and the v2 framework's
|
|
175
|
+
standard mapping), or
|
|
176
|
+
- the legacy Snowflake envelope a ``Snowflake Token=`` session token can draw —
|
|
177
|
+
HTTP **200** (or a 4xx) with a JSON body ``{"success": false, "code":
|
|
178
|
+
"390111", ...}`` (390111/390112/390114/390115; see ``REAUTH_GS_CODES``).
|
|
179
|
+
|
|
180
|
+
The body is inspected only when the response declares JSON, and a body with
|
|
181
|
+
``success: true`` or a non-reauth ``code`` (i.e. every normal domain response)
|
|
182
|
+
is not a reauth signal — so this never mistakes an ordinary 2xx for expiry.
|
|
183
|
+
"""
|
|
184
|
+
if resp.status_code in (401, 403):
|
|
185
|
+
return True
|
|
186
|
+
if "json" not in resp.headers.get("content-type", "").lower():
|
|
187
|
+
return False
|
|
188
|
+
try:
|
|
189
|
+
body = resp.json()
|
|
190
|
+
except Exception: # noqa: BLE001 - unparseable/non-JSON → not a reauth envelope
|
|
191
|
+
return False
|
|
192
|
+
if not isinstance(body, dict) or body.get("success") is True:
|
|
193
|
+
return False
|
|
194
|
+
return str(body.get("code") or "") in REAUTH_GS_CODES
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _is_clean_goaway(exc: BaseException) -> bool:
|
|
198
|
+
"""True when the error is an HTTP/2 GOAWAY with NO_ERROR before any streams.
|
|
199
|
+
|
|
200
|
+
HTTP/2 servers send GOAWAY frames to gracefully close connections. When the
|
|
201
|
+
frame has error_code=0 (NO_ERROR) and last_stream_id is very high (meaning
|
|
202
|
+
no streams were processed on this connection), the server is telling us it
|
|
203
|
+
closed the connection before processing our request. This is safe to retry
|
|
204
|
+
even for non-idempotent methods.
|
|
205
|
+
|
|
206
|
+
The error message format from httpcore is:
|
|
207
|
+
<ConnectionTerminated error_code:0, last_stream_id:2147483647, additional_data:None>
|
|
208
|
+
"""
|
|
209
|
+
msg = str(exc)
|
|
210
|
+
if "ConnectionTerminated" not in msg:
|
|
211
|
+
return False
|
|
212
|
+
# error_code:0 or error_code: 0 means NO_ERROR (graceful close)
|
|
213
|
+
if "error_code:0" not in msg and "error_code: 0" not in msg:
|
|
214
|
+
return False
|
|
215
|
+
# last_stream_id:2147483647 means no streams were processed (max value)
|
|
216
|
+
# This is the key indicator that our request wasn't touched
|
|
217
|
+
return "last_stream_id:2147483647" in msg or "last_stream_id: 2147483647" in msg
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _retry_after_seconds(resp: httpx.Response) -> float | None:
|
|
221
|
+
"""Seconds to wait from a ``Retry-After`` header, or ``None`` if unusable.
|
|
222
|
+
|
|
223
|
+
Sanitized against a hostile or malformed header:
|
|
224
|
+
|
|
225
|
+
- ``inf``/``nan`` are rejected (``None``) so the caller falls back to bounded
|
|
226
|
+
exponential backoff rather than ``sleep(inf)``.
|
|
227
|
+
- a finite value is clamped to ``[0, _RETRY_AFTER_MAX_S]`` -- a negative delta
|
|
228
|
+
(a stale HTTP-date) becomes ``0``, and an absurd multi-hour value is capped.
|
|
229
|
+
- both header forms are supported: delay-seconds (``"120"``) and an HTTP-date
|
|
230
|
+
(``"Wed, 21 Oct 2026 07:28:00 GMT"``, converted to a delta from now).
|
|
231
|
+
"""
|
|
232
|
+
raw = resp.headers.get("Retry-After")
|
|
233
|
+
if not raw:
|
|
234
|
+
return None
|
|
235
|
+
raw = raw.strip()
|
|
236
|
+
try:
|
|
237
|
+
value = float(raw)
|
|
238
|
+
except ValueError:
|
|
239
|
+
# Not a number -- try the HTTP-date form.
|
|
240
|
+
try:
|
|
241
|
+
when = parsedate_to_datetime(raw)
|
|
242
|
+
except (TypeError, ValueError):
|
|
243
|
+
return None
|
|
244
|
+
if when is None:
|
|
245
|
+
return None
|
|
246
|
+
import datetime as _dt
|
|
247
|
+
|
|
248
|
+
now = _dt.datetime.now(_dt.UTC)
|
|
249
|
+
if when.tzinfo is None:
|
|
250
|
+
when = when.replace(tzinfo=_dt.UTC)
|
|
251
|
+
value = (when - now).total_seconds()
|
|
252
|
+
if not math.isfinite(value):
|
|
253
|
+
return None
|
|
254
|
+
return max(0.0, min(value, _RETRY_AFTER_MAX_S))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _rate_limit_delay(resp: httpx.Response, attempt: int, rng: random.Random) -> float | None:
|
|
258
|
+
"""Delay before retry ``attempt`` of a 429, or ``None`` when the budget is spent.
|
|
259
|
+
|
|
260
|
+
Honors a sanitized ``Retry-After`` when present, otherwise falls back to the
|
|
261
|
+
cold-start backoff schedule. Jitter is applied **upward only** on a server
|
|
262
|
+
hint: the server told us the earliest safe moment, so retrying before it would
|
|
263
|
+
violate the contract and re-hammer a gateway that just asked us to back off.
|
|
264
|
+
"""
|
|
265
|
+
if attempt > _RATE_LIMIT_MAX_ATTEMPTS:
|
|
266
|
+
return None
|
|
267
|
+
hint = _retry_after_seconds(resp)
|
|
268
|
+
if hint is not None:
|
|
269
|
+
return hint + rng.uniform(0.0, 0.25) * hint
|
|
270
|
+
return compute_backoff(attempt, delays=DEFAULT_COLD_START_DELAYS_S, rng=rng) or 1.0
|