From e3b9e3325613befa03ac7468017c9e9bd4cd8e63 Mon Sep 17 00:00:00 2001 From: TiM Date: Tue, 20 Oct 2026 09:45:09 +1300 Subject: [PATCH 1/3] fix: raise SnowflakeForbiddenError for HTTP 403 - A 403 means Snowflake accepted the JWT and refused the request, which callers want to tell apart from a malformed request without parsing status_code. It now raises SnowflakeForbiddenError instead of the generic SnowflakeRequestError. - SnowflakeForbiddenError subclasses SnowflakeRequestError, so every existing except SnowflakeRequestError still catches a 403 (non-breaking). - It carries status_code, code, sql_state and statement_handle like the generic branch, and is exported from the package root. - Sync and async regression tests cover the mapping; troubleshooting and authentication docs gain a 403 section. --- docs/authentication.md | 6 +++- docs/troubleshooting.md | 31 +++++++++++++++++ snowflake_sql_api/__init__.py | 2 ++ snowflake_sql_api/exceptions.py | 10 ++++++ snowflake_sql_api/transport.py | 13 +++++-- tests/test_package.py | 3 ++ tests/test_regression.py | 62 +++++++++++++++++++++++++++++++-- 7 files changed, 121 insertions(+), 6 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index f01ac7b..af542eb 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 34af05a..011cb46 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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 | @@ -32,6 +33,36 @@ 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 it is not retried. 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, diff --git a/snowflake_sql_api/__init__.py b/snowflake_sql_api/__init__.py index 94dc923..b2a3a6c 100644 --- a/snowflake_sql_api/__init__.py +++ b/snowflake_sql_api/__init__.py @@ -33,6 +33,7 @@ SnowflakeAuthError, SnowflakeConfigError, SnowflakeError, + SnowflakeForbiddenError, SnowflakeProgrammingError, SnowflakeRequestError, SnowflakeRetryError, @@ -51,6 +52,7 @@ "SnowflakeConfigError", "SnowflakeAuthError", "SnowflakeRequestError", + "SnowflakeForbiddenError", "SnowflakeProgrammingError", "SnowflakeTimeoutError", "SnowflakeRetryError", diff --git a/snowflake_sql_api/exceptions.py b/snowflake_sql_api/exceptions.py index 838f7cf..0ebf7b4 100644 --- a/snowflake_sql_api/exceptions.py +++ b/snowflake_sql_api/exceptions.py @@ -14,6 +14,7 @@ "SnowflakeConfigError", "SnowflakeAuthError", "SnowflakeRequestError", + "SnowflakeForbiddenError", "SnowflakeProgrammingError", "SnowflakeTimeoutError", "SnowflakeRetryError", @@ -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). diff --git a/snowflake_sql_api/transport.py b/snowflake_sql_api/transport.py index 0b0b330..dadc01e 100644 --- a/snowflake_sql_api/transport.py +++ b/snowflake_sql_api/transport.py @@ -1,8 +1,8 @@ """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 +(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. @@ -19,6 +19,7 @@ from .exceptions import ( SnowflakeAuthError, + SnowflakeForbiddenError, SnowflakeProgrammingError, SnowflakeRequestError, SnowflakeRetryError, @@ -84,6 +85,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, diff --git a/tests/test_package.py b/tests/test_package.py index 7fc7e38..dee5cf4 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -10,6 +10,7 @@ from snowflake_sql_api.exceptions import ( ResultNotReady, SnowflakeError, + SnowflakeForbiddenError, SnowflakeProgrammingError, SnowflakeRequestError, ) @@ -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: diff --git a/tests/test_regression.py b/tests/test_regression.py index bb9fb8e..3d8ee7b 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -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 @@ -11,9 +11,13 @@ 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 @@ -21,6 +25,13 @@ 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. @@ -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 From 6828d2bcdc55f34cacbadddfb02bac85970b610f Mon Sep 17 00:00:00 2001 From: TiM Date: Tue, 20 Oct 2026 09:51:26 +1300 Subject: [PATCH 2/3] fix(analysis): qualify the 403 no-retry claim for custom retry policies - troubleshooting: the default RetryPolicy does not retry a 403; a custom policy listing it surfaces SnowflakeRetryError with a plain SnowflakeRequestError cause. - testing.md: show register_error(status=403) raising SnowflakeForbiddenError. - transport module docstring reflowed to the line length. --- docs/testing.md | 3 +++ docs/troubleshooting.md | 5 ++++- snowflake_sql_api/transport.py | 9 +++++---- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 01753ac..c876677 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 011cb46..457f8c3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -36,7 +36,10 @@ Work through these in order: ## Forbidden requests (403 / `SnowflakeForbiddenError`) The JWT was accepted, but Snowflake refused the request. Retrying with the same -identity will not help, so it is not retried. The exception carries 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. diff --git a/snowflake_sql_api/transport.py b/snowflake_sql_api/transport.py index dadc01e..7204931 100644 --- a/snowflake_sql_api/transport.py +++ b/snowflake_sql_api/transport.py @@ -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, 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. +(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``. """ From 385117c2585674289cd1128176b7e1ff85255c46 Mon Sep 17 00:00:00 2001 From: TiM Date: Tue, 20 Oct 2026 09:53:06 +1300 Subject: [PATCH 3/3] test(review): pin the exhausted-retry cause for a custom policy that retries 403 --- tests/test_transport.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_transport.py b/tests/test_transport.py index 9cfa800..7cdb629 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -9,6 +9,7 @@ from snowflake_sql_api.auth import KeypairAuthenticator from snowflake_sql_api.exceptions import ( SnowflakeAuthError, + SnowflakeForbiddenError, SnowflakeProgrammingError, SnowflakeRequestError, SnowflakeRetryError, @@ -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(