Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,8 @@ A `SnowflakeAuthError` (HTTP 401) almost always means one of:
- an encrypted key supplied without (or with the wrong) passphrase, which raises
a `SnowflakeConfigError` before any request.

See [troubleshooting.md](troubleshooting.md) for more.
A `SnowflakeForbiddenError` (HTTP 403) is a different failure: the key and
claim were accepted, and Snowflake refused the request itself (for example a
network policy, or the SQL API not being enabled). See
[troubleshooting.md](troubleshooting.md#forbidden-requests-403--snowflakeforbiddenerror)
for both.
3 changes: 3 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ assert client.execute("DELETE FROM t WHERE id = 1") == 1

fake.register_error("SELECT bad syntax", "SQL compilation error", code="000904")
# client.query("SELECT bad syntax") now raises SnowflakeProgrammingError

fake.register_error("SELECT 1", "SQL API is not enabled", status=403)
# client.query("SELECT 1") now raises SnowflakeForbiddenError
```

## Multi-partition results
Expand Down
34 changes: 34 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ types below.
|-----------|-------------|
| `SnowflakeConfigError` | bad/missing config, before any request (no key, unreadable key, missing extra) |
| `SnowflakeAuthError` | JWT generation or Snowflake auth failed (HTTP 401) |
| `SnowflakeForbiddenError` | Snowflake refused an authenticated request (HTTP 403); a `SnowflakeRequestError` subclass |
| `SnowflakeProgrammingError` | the SQL failed to compile or execute (HTTP 422) |
| `SnowflakeRequestError` | an HTTP/protocol-level failure (carries `code`, `sql_state`, `status_code`) |
| `SnowflakeTimeoutError` | an HTTP request or a statement exceeded its timeout |
Expand All @@ -32,6 +33,39 @@ Work through these in order:
`private_key_passphrase` raises `SnowflakeConfigError` at construction, not a
401.

## Forbidden requests (403 / `SnowflakeForbiddenError`)

The JWT was accepted, but Snowflake refused the request. Retrying with the same
identity will not help, so the default `RetryPolicy` does not retry it. (A
custom policy that lists 403 in `retry_statuses` surfaces it as
`SnowflakeRetryError` instead, with a plain `SnowflakeRequestError` as the
`__cause__`.) The exception carries the same
fields as `SnowflakeRequestError` (`status_code`, `code`, `sql_state`,
`statement_handle`) and subclasses it, so an existing
`except SnowflakeRequestError` still catches it.

Read the message first; it usually names the cause. Common ones:

1. **SQL API not enabled.** Snowflake's SQL API reference gives this as the
cause of a 403. Check with your account administrator.
2. **Network policy.** If the message names an IP address, an account- or
user-level network policy is blocking the caller, whatever the key. Compare
the egress IP your requests actually leave from (NAT, proxy, VPC gateway)
against the policy's allowed list.

A role that lacks a privilege on a table or view is a different failure: the
statement reaches Snowflake and fails to compile, which comes back as a 422
`SnowflakeProgrammingError` ("does not exist or not authorized"), not a 403.

```python
from snowflake_sql_api.exceptions import SnowflakeForbiddenError

try:
client.query("SELECT 1")
except SnowflakeForbiddenError as exc:
print(exc.code, exc)
```

## SQL errors (422 / `SnowflakeProgrammingError`)

The statement reached Snowflake and was rejected (syntax error, missing object,
Expand Down
2 changes: 2 additions & 0 deletions snowflake_sql_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
SnowflakeAuthError,
SnowflakeConfigError,
SnowflakeError,
SnowflakeForbiddenError,
SnowflakeProgrammingError,
SnowflakeRequestError,
SnowflakeRetryError,
Expand All @@ -51,6 +52,7 @@
"SnowflakeConfigError",
"SnowflakeAuthError",
"SnowflakeRequestError",
"SnowflakeForbiddenError",
"SnowflakeProgrammingError",
"SnowflakeTimeoutError",
"SnowflakeRetryError",
Expand Down
10 changes: 10 additions & 0 deletions snowflake_sql_api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"SnowflakeConfigError",
"SnowflakeAuthError",
"SnowflakeRequestError",
"SnowflakeForbiddenError",
"SnowflakeProgrammingError",
"SnowflakeTimeoutError",
"SnowflakeRetryError",
Expand Down Expand Up @@ -62,6 +63,15 @@ def __init__(
self.statement_handle = statement_handle


class SnowflakeForbiddenError(SnowflakeRequestError):
"""Snowflake refused an authenticated request (HTTP 403).

The JWT was accepted but the request is not allowed, so retrying with the
same identity will not help. A subclass of :class:`SnowflakeRequestError`,
so existing handlers for that type still catch it.
"""


class SnowflakeProgrammingError(SnowflakeRequestError):
"""The submitted SQL failed to compile or execute (HTTP 422).

Expand Down
18 changes: 14 additions & 4 deletions snowflake_sql_api/transport.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""HTTP transport against ``POST /api/v2/statements``.

Owns the actual ``httpx`` calls (sync and async), status-code interpretation
(200 success, 202 long-running/async, 422 execution failure, 401 auth, 429/5xx
retryable), the retry loop, and ``requestId`` reuse for idempotent retries. A
retried submit re-POSTs the identical body with the same ``requestId`` and
``retry=true`` so Snowflake deduplicates instead of double-applying DML.
(200 success, 202 long-running/async, 422 execution failure, 401 auth,
403 forbidden, 429/5xx retryable), the retry loop, and ``requestId`` reuse for
idempotent retries. A retried submit re-POSTs the identical body with the same
``requestId`` and ``retry=true`` so Snowflake deduplicates instead of
double-applying DML.

Higher layers (``client``/``aclient``) talk to the transport, not to ``httpx``.
"""
Expand All @@ -19,6 +20,7 @@

from .exceptions import (
SnowflakeAuthError,
SnowflakeForbiddenError,
SnowflakeProgrammingError,
SnowflakeRequestError,
SnowflakeRetryError,
Expand Down Expand Up @@ -84,6 +86,14 @@ def _interpret(response: httpx.Response) -> StatementResponse:
)
if status == 401:
raise SnowflakeAuthError(body.get("message", "authentication failed"))
if status == 403:
raise SnowflakeForbiddenError(
body.get("message", "request forbidden"),
status_code=status,
code=body.get("code"),
sql_state=body.get("sqlState"),
statement_handle=body.get("statementHandle"),
)
raise SnowflakeRequestError(
body.get("message", f"HTTP {status}"),
status_code=status,
Expand Down
3 changes: 3 additions & 0 deletions tests/test_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from snowflake_sql_api.exceptions import (
ResultNotReady,
SnowflakeError,
SnowflakeForbiddenError,
SnowflakeProgrammingError,
SnowflakeRequestError,
)
Expand Down Expand Up @@ -39,6 +40,8 @@ def test_exception_hierarchy() -> None:
assert issubclass(ResultNotReady, SnowflakeError)
# A programming error is a request error (HTTP 422 specialization).
assert issubclass(SnowflakeProgrammingError, SnowflakeRequestError)
# So is a forbidden error (HTTP 403 specialization), keeping 403 non-breaking.
assert issubclass(SnowflakeForbiddenError, SnowflakeRequestError)


def test_request_error_carries_envelope_fields() -> None:
Expand Down
62 changes: 59 additions & 3 deletions tests/test_regression.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Named regression tests for the four spike-discovered bugs.
"""Named regression tests for the four spike-discovered bugs, plus later fixes.

Each fixed bug gets a test marked ``regression`` so ``pytest -k regression``
proves they stay fixed. Bug #2 (on_query in the streaming path) lands with
Expand All @@ -11,16 +11,27 @@
import pytest
import respx

from snowflake_sql_api import SnowflakeClient
from snowflake_sql_api import AsyncSnowflakeClient, SnowflakeClient
from snowflake_sql_api.auth import normalize_account_locator
from snowflake_sql_api.exceptions import ResultNotReady
from snowflake_sql_api.exceptions import (
ResultNotReady,
SnowflakeForbiddenError,
SnowflakeRequestError,
)

from .support import ACCOUNT, STATEMENTS_URL, USER, ok_body, running_body, statement_url

pytestmark = pytest.mark.regression

INT_COL = [{"name": "N", "type": "fixed", "scale": 0}]

FORBIDDEN_BODY = {
"code": "TEST403",
"message": "SQL API is not enabled for this account",
"sqlState": "TEST1",
"statementHandle": "forbidden-handle",
}


def test_regression_bug1_jwt_account_locator_strips_region() -> None:
"""Bug #1: the JWT claim account must drop the region/cloud suffix.
Expand Down Expand Up @@ -85,3 +96,48 @@ def by_partition(request: httpx.Request) -> httpx.Response:
client.close()
# Partition 0 (inline) + partitions 1 and 2, in order, none dropped.
assert [r["N"] for r in rows] == [0, 1, 2, 3]


def _assert_forbidden(exc: SnowflakeForbiddenError) -> None:
# Still a SnowflakeRequestError, so existing handlers keep catching 403.
assert isinstance(exc, SnowflakeRequestError)
assert exc.status_code == 403
assert exc.code == "TEST403"
assert exc.sql_state == "TEST1"
assert exc.statement_handle == "forbidden-handle"
assert str(exc) == "SQL API is not enabled for this account"


@respx.mock
def test_regression_403_raises_forbidden_error(private_key_pem: bytes) -> None:
"""HTTP 403 maps to SnowflakeForbiddenError, not the generic request error.

An authenticated-but-refused request must be distinguishable from a
malformed one without parsing ``status_code``.
"""
route = respx.post(STATEMENTS_URL).mock(
return_value=httpx.Response(403, json=FORBIDDEN_BODY)
)
client = SnowflakeClient(ACCOUNT, USER, private_key=private_key_pem)
with pytest.raises(SnowflakeForbiddenError) as info:
client.query("SELECT 1")
client.close()
_assert_forbidden(info.value)
# 403 is not retryable: one request, no retry loop.
assert route.call_count == 1


@respx.mock
async def test_regression_403_raises_forbidden_error_async(
private_key_pem: bytes,
) -> None:
"""Async parity for the 403 mapping."""
route = respx.post(STATEMENTS_URL).mock(
return_value=httpx.Response(403, json=FORBIDDEN_BODY)
)
client = AsyncSnowflakeClient(ACCOUNT, USER, private_key=private_key_pem)
with pytest.raises(SnowflakeForbiddenError) as info:
await client.query("SELECT 1")
await client.aclose()
_assert_forbidden(info.value)
assert route.call_count == 1
23 changes: 23 additions & 0 deletions tests/test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from snowflake_sql_api.auth import KeypairAuthenticator
from snowflake_sql_api.exceptions import (
SnowflakeAuthError,
SnowflakeForbiddenError,
SnowflakeProgrammingError,
SnowflakeRequestError,
SnowflakeRetryError,
Expand Down Expand Up @@ -148,6 +149,28 @@ def test_retry_exhaustion_raises_retry_error(private_key_pem: bytes) -> None:
assert route.call_count == 3 # max_attempts


@respx.mock
def test_custom_policy_retrying_403_chains_plain_request_error(
private_key_pem: bytes,
) -> None:
# Retried statuses never reach _interpret, so an exhausted 403 retry carries
# the generic SnowflakeRequestError as its cause, like every other status.
route = respx.post(STATEMENTS_URL).mock(
return_value=httpx.Response(403, json={"message": "forbidden"})
)
retry_403 = RetryPolicy(
max_attempts=2, base_backoff=0.0, jitter=False, retry_statuses=frozenset({403})
)
transport = make_transport(private_key_pem, retry=retry_403)
with pytest.raises(SnowflakeRetryError) as info:
transport.submit({"statement": "x"}, request_id="r1")
cause = info.value.__cause__
assert isinstance(cause, SnowflakeRequestError)
assert not isinstance(cause, SnowflakeForbiddenError)
assert cause.status_code == 403
assert route.call_count == 2


@respx.mock
def test_network_error_is_retried(private_key_pem: bytes) -> None:
route = respx.post(STATEMENTS_URL).mock(
Expand Down
Loading