From c40db021a891da1768d065773b923d044db08bc2 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 1 Sep 2026 07:39:12 -0300 Subject: [PATCH 1/3] fix(functions): stop per-call invoke options leaking onto the client `invoke` aliased `self.headers` instead of copying it, so the per-call `headers`, `x-region`, and `Content-Type` writes mutated the client's default headers. Every later `invoke()` on the same client then carried the previous call's headers and region. Build the merged header dict in `_request` instead: client defaults first, per-call headers second, so per-call values still win and neither dict is mutated. Also tolerate a non-JSON error body. Both the HTTP and relay error paths called `response.json().get("error")`, which raised `JSONDecodeError` on a plain-text or empty edge function response and masked the real HTTP error. `error_message_from` falls back to the response text. Co-Authored-By: Claude --- .../_async/functions_client.py | 31 ++++++++++++------- .../_sync/functions_client.py | 31 ++++++++++++------- .../src/supabase_functions/errors.py | 20 +++++++++++- src/functions/tests/test_errors.py | 18 ++++++++++- 4 files changed, 76 insertions(+), 24 deletions(-) diff --git a/src/functions/src/supabase_functions/_async/functions_client.py b/src/functions/src/supabase_functions/_async/functions_client.py index 2f7529b1..0ef93ac9 100644 --- a/src/functions/src/supabase_functions/_async/functions_client.py +++ b/src/functions/src/supabase_functions/_async/functions_client.py @@ -6,7 +6,7 @@ from httpx import AsyncClient, HTTPError, QueryParams, Response from yarl import URL -from ..errors import FunctionsHttpError, FunctionsRelayError +from ..errors import FunctionsHttpError, FunctionsRelayError, error_message_from from ..utils import ( FunctionRegion, is_http_url, @@ -14,6 +14,8 @@ ) from ..version import __version__ +HTTPMethod = Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"] + class AsyncFunctionsClient: def __init__( @@ -77,20 +79,19 @@ def __init__( async def _request( self, - method: Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"], + method: HTTPMethod, path: list[str], headers: Optional[Dict[str, str]] = None, - json: Optional[Dict[Any, Any]] = None, + json: Optional[Union[Dict[Any, Any], str, bytes]] = None, params: Optional[QueryParams] = None, ) -> Response: url = self.url.joinpath(*path) - headers = headers or dict() - headers.update(self.headers) + headers = {**self.headers, **(headers or {})} response = ( await self._client.request( - method, str(url), data=json, headers=headers, params=params + method, str(url), content=json, headers=headers, params=params ) - if isinstance(json, str) + if isinstance(json, (str, bytes)) else await self._client.request( method, str(url), json=json, headers=headers, params=params ) @@ -103,7 +104,7 @@ async def _request( status_code = response.status_code raise FunctionsHttpError( - response.json().get("error") + error_message_from(response) or f"An error occurred while requesting your edge function at {exc.request.url!r}.", status_code, ) from exc @@ -132,17 +133,20 @@ async def invoke( invoke_options : object with the following properties `headers`: object representing the headers to send with the request `body`: the body of the request + `method`: the HTTP method to invoke the function with. The default is `POST` `responseType`: how the response should be parsed. The default is `json` """ if not is_valid_str_arg(function_name): raise ValueError("function_name must a valid string value.") - headers = self.headers + headers: Dict[str, str] = {} params = QueryParams() body = None + method: HTTPMethod = "POST" response_type = "text/plain" if invoke_options is not None: headers.update(invoke_options.get("headers", {})) + method = invoke_options.get("method", "POST") response_type = invoke_options.get("responseType", "text/plain") region = invoke_options.get("region") @@ -161,14 +165,19 @@ async def invoke( headers["Content-Type"] = "text/plain" elif isinstance(body, dict): headers["Content-Type"] = "application/json" + elif isinstance(body, bytes): + headers["Content-Type"] = "application/octet-stream" response = await self._request( - "POST", [function_name], headers=headers, json=body, params=params + method, [function_name], headers=headers, json=body, params=params ) is_relay_error = response.headers.get("x-relay-header") if is_relay_error and is_relay_error == "true": - raise FunctionsRelayError(response.json().get("error")) + raise FunctionsRelayError( + error_message_from(response) + or "An error occurred while relaying your edge function request." + ) if response_type == "json": data = response.json() diff --git a/src/functions/src/supabase_functions/_sync/functions_client.py b/src/functions/src/supabase_functions/_sync/functions_client.py index 18f073d7..1363755c 100644 --- a/src/functions/src/supabase_functions/_sync/functions_client.py +++ b/src/functions/src/supabase_functions/_sync/functions_client.py @@ -6,7 +6,7 @@ from httpx import Client, HTTPError, QueryParams, Response from yarl import URL -from ..errors import FunctionsHttpError, FunctionsRelayError +from ..errors import FunctionsHttpError, FunctionsRelayError, error_message_from from ..utils import ( FunctionRegion, is_http_url, @@ -14,6 +14,8 @@ ) from ..version import __version__ +HTTPMethod = Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"] + class SyncFunctionsClient: def __init__( @@ -77,20 +79,19 @@ def __init__( def _request( self, - method: Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"], + method: HTTPMethod, path: list[str], headers: Optional[Dict[str, str]] = None, - json: Optional[Dict[Any, Any]] = None, + json: Optional[Union[Dict[Any, Any], str, bytes]] = None, params: Optional[QueryParams] = None, ) -> Response: url = self.url.joinpath(*path) - headers = headers or dict() - headers.update(self.headers) + headers = {**self.headers, **(headers or {})} response = ( self._client.request( - method, str(url), data=json, headers=headers, params=params + method, str(url), content=json, headers=headers, params=params ) - if isinstance(json, str) + if isinstance(json, (str, bytes)) else self._client.request( method, str(url), json=json, headers=headers, params=params ) @@ -103,7 +104,7 @@ def _request( status_code = response.status_code raise FunctionsHttpError( - response.json().get("error") + error_message_from(response) or f"An error occurred while requesting your edge function at {exc.request.url!r}.", status_code, ) from exc @@ -132,17 +133,20 @@ def invoke( invoke_options : object with the following properties `headers`: object representing the headers to send with the request `body`: the body of the request + `method`: the HTTP method to invoke the function with. The default is `POST` `responseType`: how the response should be parsed. The default is `json` """ if not is_valid_str_arg(function_name): raise ValueError("function_name must a valid string value.") - headers = self.headers + headers: Dict[str, str] = {} params = QueryParams() body = None + method: HTTPMethod = "POST" response_type = "text/plain" if invoke_options is not None: headers.update(invoke_options.get("headers", {})) + method = invoke_options.get("method", "POST") response_type = invoke_options.get("responseType", "text/plain") region = invoke_options.get("region") @@ -161,14 +165,19 @@ def invoke( headers["Content-Type"] = "text/plain" elif isinstance(body, dict): headers["Content-Type"] = "application/json" + elif isinstance(body, bytes): + headers["Content-Type"] = "application/octet-stream" response = self._request( - "POST", [function_name], headers=headers, json=body, params=params + method, [function_name], headers=headers, json=body, params=params ) is_relay_error = response.headers.get("x-relay-header") if is_relay_error and is_relay_error == "true": - raise FunctionsRelayError(response.json().get("error")) + raise FunctionsRelayError( + error_message_from(response) + or "An error occurred while relaying your edge function request." + ) if response_type == "json": data = response.json() diff --git a/src/functions/src/supabase_functions/errors.py b/src/functions/src/supabase_functions/errors.py index 0529e344..50277a15 100644 --- a/src/functions/src/supabase_functions/errors.py +++ b/src/functions/src/supabase_functions/errors.py @@ -1,6 +1,9 @@ from __future__ import annotations -from typing import TypedDict +from typing import TYPE_CHECKING, TypedDict + +if TYPE_CHECKING: + from httpx import Response class FunctionsApiErrorDict(TypedDict): @@ -42,3 +45,18 @@ def __init__(self, message: str, code: int | None = None) -> None: "FunctionsRelayError", 400 if code is None else code, ) + + +def error_message_from(response: Response) -> str | None: + """Best-effort extraction of an error message from an edge function response. + + An edge function may reply with a plain-text or empty body, so a failed JSON + decode must not mask the underlying HTTP error. + """ + try: + body = response.json() + except ValueError: + return response.text or None + if isinstance(body, dict): + return body.get("error") + return response.text or None diff --git a/src/functions/tests/test_errors.py b/src/functions/tests/test_errors.py index 11eca4e0..c40634dc 100644 --- a/src/functions/tests/test_errors.py +++ b/src/functions/tests/test_errors.py @@ -1,11 +1,13 @@ -from typing import Type +from typing import Optional, Type import pytest +from httpx import Response from supabase_functions.errors import ( FunctionsApiErrorDict, FunctionsError, FunctionsHttpError, FunctionsRelayError, + error_message_from, ) @@ -105,3 +107,17 @@ def test_error_message_types() -> None: error = FunctionsError(message, "test", 500) assert error.message == message assert error.to_dict()["message"] == message + + +@pytest.mark.parametrize( + "response,expected", + [ + (Response(500, json={"error": "boom"}), "boom"), + (Response(500, json={"detail": "boom"}), None), + (Response(500, json=["boom"]), '["boom"]'), + (Response(500, text="plain boom"), "plain boom"), + (Response(500), None), + ], +) +def test_error_message_from(response: Response, expected: Optional[str]) -> None: + assert error_message_from(response) == expected From 2620bf7f2f5e3114e9591d753916ed25de71dd6c Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 1 Sep 2026 07:39:15 -0300 Subject: [PATCH 2/3] feat(functions): support non-POST methods and bytes bodies in invoke `invoke` hard-coded POST. Accept a `method` key in `invoke_options`, defaulting to POST, matching supabase-js's `FunctionInvokeOptions`. Accept a `bytes` body as well, sent as `application/octet-stream`. String and bytes bodies now go through httpx's `content=` rather than the deprecated `data=`. Co-Authored-By: Claude --- .../tests/_async/test_function_client.py | 184 +++++++++++++++++- .../tests/_sync/test_function_client.py | 166 +++++++++++++++- 2 files changed, 348 insertions(+), 2 deletions(-) diff --git a/src/functions/tests/_async/test_function_client.py b/src/functions/tests/_async/test_function_client.py index a821deb8..3866c251 100644 --- a/src/functions/tests/_async/test_function_client.py +++ b/src/functions/tests/_async/test_function_client.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from httpx import AsyncClient, HTTPError, Response, Timeout +from httpx import AsyncClient, HTTPError, Request, Response, Timeout # Import the class to test from supabase_functions import AsyncFunctionsClient @@ -229,3 +229,185 @@ async def test_init_with_httpx_client() -> None: # Verify the client is properly configured with our custom client assert client._client is custom_client + + +async def test_invoke_does_not_leak_per_call_headers( + client: AsyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object( + client._client, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = mock_response + + await client.invoke( + "test-function", + { + "headers": {"x-one-off": "yes"}, + "region": FunctionRegion("us-east-1"), + "body": {"key": "value"}, + }, + ) + await client.invoke("test-function") + + first, second = mock_request.call_args_list + assert first.kwargs["headers"]["x-one-off"] == "yes" + assert "x-one-off" not in second.kwargs["headers"] + assert "x-region" not in second.kwargs["headers"] + assert "Content-Type" not in second.kwargs["headers"] + assert "x-one-off" not in client.headers + + +async def test_invoke_per_call_headers_override_client_headers( + client: AsyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object( + client._client, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = mock_response + + await client.invoke( + "test-function", {"headers": {"Authorization": "Bearer override"}} + ) + + _, kwargs = mock_request.call_args + assert kwargs["headers"]["Authorization"] == "Bearer override" + assert client.headers["Authorization"] == "Bearer valid.jwt.token" + + +@pytest.mark.parametrize("method", ["GET", "PUT", "PATCH", "DELETE"]) +async def test_invoke_with_method(client: AsyncFunctionsClient, method: str) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object( + client._client, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = mock_response + + await client.invoke("test-function", {"method": method}) + + args, _ = mock_request.call_args + assert args[0] == method + + +async def test_invoke_defaults_to_post(client: AsyncFunctionsClient) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object( + client._client, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = mock_response + + await client.invoke("test-function") + + args, _ = mock_request.call_args + assert args[0] == "POST" + + +async def test_invoke_with_bytes_body(client: AsyncFunctionsClient) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object( + client._client, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = mock_response + + await client.invoke("test-function", {"body": b"\x00binary"}) + + _, kwargs = mock_request.call_args + assert kwargs["headers"]["Content-Type"] == "application/octet-stream" + assert kwargs["content"] == b"\x00binary" + + +async def test_invoke_string_body_sent_as_content( + client: AsyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object( + client._client, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = mock_response + + await client.invoke("test-function", {"body": "string data"}) + + _, kwargs = mock_request.call_args + assert kwargs["content"] == "string data" + + +async def test_invoke_http_error_with_non_json_body( + client: AsyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.side_effect = ValueError("not json") + mock_response.text = "boom" + mock_response.raise_for_status.side_effect = HTTPError("HTTP Error") + mock_response.headers = {} + + with patch.object( + client._client, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = mock_response + + with pytest.raises(FunctionsHttpError, match="boom"): + await client.invoke("test-function") + + +async def test_invoke_http_error_with_empty_body(client: AsyncFunctionsClient) -> None: + error = HTTPError("HTTP Error") + error.request = Request("POST", "https://example.com/test-function") + + mock_response = Mock(spec=Response) + mock_response.json.side_effect = ValueError("not json") + mock_response.text = "" + mock_response.raise_for_status.side_effect = error + mock_response.headers = {} + + with patch.object( + client._client, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = mock_response + + with pytest.raises( + FunctionsHttpError, match="An error occurred while requesting" + ): + await client.invoke("test-function") + + +async def test_invoke_relay_error_with_non_json_body( + client: AsyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.side_effect = ValueError("not json") + mock_response.text = "relay exploded" + mock_response.raise_for_status = Mock() + mock_response.headers = {"x-relay-header": "true"} + + with patch.object( + client._client, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = mock_response + + with pytest.raises(FunctionsRelayError, match="relay exploded"): + await client.invoke("test-function") diff --git a/src/functions/tests/_sync/test_function_client.py b/src/functions/tests/_sync/test_function_client.py index 6be348df..7aa14879 100644 --- a/src/functions/tests/_sync/test_function_client.py +++ b/src/functions/tests/_sync/test_function_client.py @@ -3,7 +3,7 @@ from unittest.mock import Mock, patch import pytest -from httpx import Client, HTTPError, Response, Timeout +from httpx import Client, HTTPError, Request, Response, Timeout # Import the class to test from supabase_functions import SyncFunctionsClient @@ -213,3 +213,167 @@ def test_init_with_httpx_client() -> None: # Verify the client is properly configured with our custom client assert client._client is custom_client + + +def test_invoke_does_not_leak_per_call_headers( + client: SyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object(client._client, "request", new_callable=Mock) as mock_request: + mock_request.return_value = mock_response + + client.invoke( + "test-function", + { + "headers": {"x-one-off": "yes"}, + "region": FunctionRegion("us-east-1"), + "body": {"key": "value"}, + }, + ) + client.invoke("test-function") + + first, second = mock_request.call_args_list + assert first.kwargs["headers"]["x-one-off"] == "yes" + assert "x-one-off" not in second.kwargs["headers"] + assert "x-region" not in second.kwargs["headers"] + assert "Content-Type" not in second.kwargs["headers"] + assert "x-one-off" not in client.headers + + +def test_invoke_per_call_headers_override_client_headers( + client: SyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object(client._client, "request", new_callable=Mock) as mock_request: + mock_request.return_value = mock_response + + client.invoke( + "test-function", {"headers": {"Authorization": "Bearer override"}} + ) + + _, kwargs = mock_request.call_args + assert kwargs["headers"]["Authorization"] == "Bearer override" + assert client.headers["Authorization"] == "Bearer valid.jwt.token" + + +@pytest.mark.parametrize("method", ["GET", "PUT", "PATCH", "DELETE"]) +def test_invoke_with_method(client: SyncFunctionsClient, method: str) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object(client._client, "request", new_callable=Mock) as mock_request: + mock_request.return_value = mock_response + + client.invoke("test-function", {"method": method}) + + args, _ = mock_request.call_args + assert args[0] == method + + +def test_invoke_defaults_to_post(client: SyncFunctionsClient) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object(client._client, "request", new_callable=Mock) as mock_request: + mock_request.return_value = mock_response + + client.invoke("test-function") + + args, _ = mock_request.call_args + assert args[0] == "POST" + + +def test_invoke_with_bytes_body(client: SyncFunctionsClient) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object(client._client, "request", new_callable=Mock) as mock_request: + mock_request.return_value = mock_response + + client.invoke("test-function", {"body": b"\x00binary"}) + + _, kwargs = mock_request.call_args + assert kwargs["headers"]["Content-Type"] == "application/octet-stream" + assert kwargs["content"] == b"\x00binary" + + +def test_invoke_string_body_sent_as_content( + client: SyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object(client._client, "request", new_callable=Mock) as mock_request: + mock_request.return_value = mock_response + + client.invoke("test-function", {"body": "string data"}) + + _, kwargs = mock_request.call_args + assert kwargs["content"] == "string data" + + +def test_invoke_http_error_with_non_json_body( + client: SyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.side_effect = ValueError("not json") + mock_response.text = "boom" + mock_response.raise_for_status.side_effect = HTTPError("HTTP Error") + mock_response.headers = {} + + with patch.object(client._client, "request", new_callable=Mock) as mock_request: + mock_request.return_value = mock_response + + with pytest.raises(FunctionsHttpError, match="boom"): + client.invoke("test-function") + + +def test_invoke_http_error_with_empty_body(client: SyncFunctionsClient) -> None: + error = HTTPError("HTTP Error") + error.request = Request("POST", "https://example.com/test-function") + + mock_response = Mock(spec=Response) + mock_response.json.side_effect = ValueError("not json") + mock_response.text = "" + mock_response.raise_for_status.side_effect = error + mock_response.headers = {} + + with patch.object(client._client, "request", new_callable=Mock) as mock_request: + mock_request.return_value = mock_response + + with pytest.raises( + FunctionsHttpError, match="An error occurred while requesting" + ): + client.invoke("test-function") + + +def test_invoke_relay_error_with_non_json_body( + client: SyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.side_effect = ValueError("not json") + mock_response.text = "relay exploded" + mock_response.raise_for_status = Mock() + mock_response.headers = {"x-relay-header": "true"} + + with patch.object(client._client, "request", new_callable=Mock) as mock_request: + mock_request.return_value = mock_response + + with pytest.raises(FunctionsRelayError, match="relay exploded"): + client.invoke("test-function") From 4a702e3b6e9e173d538e1196ca5a2c74d504a7bb Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 1 Sep 2026 07:42:00 -0300 Subject: [PATCH 3/3] fix(functions): keep the error message helper private The capability matrix check flags `error_message_from` as new public API. It is an internal helper, not a capability, so prefix it with `_` rather than register it in sdk-compliance.yaml. Co-Authored-By: Claude --- .../src/supabase_functions/_async/functions_client.py | 6 +++--- .../src/supabase_functions/_sync/functions_client.py | 6 +++--- src/functions/src/supabase_functions/errors.py | 2 +- src/functions/tests/test_errors.py | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/functions/src/supabase_functions/_async/functions_client.py b/src/functions/src/supabase_functions/_async/functions_client.py index 0ef93ac9..479c7887 100644 --- a/src/functions/src/supabase_functions/_async/functions_client.py +++ b/src/functions/src/supabase_functions/_async/functions_client.py @@ -6,7 +6,7 @@ from httpx import AsyncClient, HTTPError, QueryParams, Response from yarl import URL -from ..errors import FunctionsHttpError, FunctionsRelayError, error_message_from +from ..errors import FunctionsHttpError, FunctionsRelayError, _error_message_from from ..utils import ( FunctionRegion, is_http_url, @@ -104,7 +104,7 @@ async def _request( status_code = response.status_code raise FunctionsHttpError( - error_message_from(response) + _error_message_from(response) or f"An error occurred while requesting your edge function at {exc.request.url!r}.", status_code, ) from exc @@ -175,7 +175,7 @@ async def invoke( if is_relay_error and is_relay_error == "true": raise FunctionsRelayError( - error_message_from(response) + _error_message_from(response) or "An error occurred while relaying your edge function request." ) diff --git a/src/functions/src/supabase_functions/_sync/functions_client.py b/src/functions/src/supabase_functions/_sync/functions_client.py index 1363755c..f023684b 100644 --- a/src/functions/src/supabase_functions/_sync/functions_client.py +++ b/src/functions/src/supabase_functions/_sync/functions_client.py @@ -6,7 +6,7 @@ from httpx import Client, HTTPError, QueryParams, Response from yarl import URL -from ..errors import FunctionsHttpError, FunctionsRelayError, error_message_from +from ..errors import FunctionsHttpError, FunctionsRelayError, _error_message_from from ..utils import ( FunctionRegion, is_http_url, @@ -104,7 +104,7 @@ def _request( status_code = response.status_code raise FunctionsHttpError( - error_message_from(response) + _error_message_from(response) or f"An error occurred while requesting your edge function at {exc.request.url!r}.", status_code, ) from exc @@ -175,7 +175,7 @@ def invoke( if is_relay_error and is_relay_error == "true": raise FunctionsRelayError( - error_message_from(response) + _error_message_from(response) or "An error occurred while relaying your edge function request." ) diff --git a/src/functions/src/supabase_functions/errors.py b/src/functions/src/supabase_functions/errors.py index 50277a15..0b7dc855 100644 --- a/src/functions/src/supabase_functions/errors.py +++ b/src/functions/src/supabase_functions/errors.py @@ -47,7 +47,7 @@ def __init__(self, message: str, code: int | None = None) -> None: ) -def error_message_from(response: Response) -> str | None: +def _error_message_from(response: Response) -> str | None: """Best-effort extraction of an error message from an edge function response. An edge function may reply with a plain-text or empty body, so a failed JSON diff --git a/src/functions/tests/test_errors.py b/src/functions/tests/test_errors.py index c40634dc..fa3d3121 100644 --- a/src/functions/tests/test_errors.py +++ b/src/functions/tests/test_errors.py @@ -7,7 +7,7 @@ FunctionsError, FunctionsHttpError, FunctionsRelayError, - error_message_from, + _error_message_from, ) @@ -120,4 +120,4 @@ def test_error_message_types() -> None: ], ) def test_error_message_from(response: Response, expected: Optional[str]) -> None: - assert error_message_from(response) == expected + assert _error_message_from(response) == expected