diff --git a/src/supabase/src/supabase/_async/client.py b/src/supabase/src/supabase/_async/client.py index ea01b7e6..ad02e22b 100644 --- a/src/supabase/src/supabase/_async/client.py +++ b/src/supabase/src/supabase/_async/client.py @@ -77,9 +77,15 @@ def __init__( self.functions_url = f"{supabase_url}/functions/v1" # Instantiate clients. + # Create a copy of client_options with a copied httpx_client for auth + # to prevent base_url mutation across services + auth_options = copy.copy(self.options) + if self.options.httpx_client is not None: + auth_options.httpx_client = copy.copy(self.options.httpx_client) + self.auth = self._init_supabase_auth_client( auth_url=self.auth_url, - client_options=self.options, + client_options=auth_options, ) self.realtime = self._init_realtime_client( realtime_url=self.realtime_url, @@ -170,12 +176,18 @@ def rpc( @property def postgrest(self): if self._postgrest is None: + # Create a copy of httpx_client to prevent base_url mutation across services + http_client = ( + copy.copy(self.options.httpx_client) + if self.options.httpx_client is not None + else None + ) self._postgrest = self._init_postgrest_client( rest_url=self.rest_url, headers=self.options.headers, schema=self.options.schema, timeout=self.options.postgrest_client_timeout, - http_client=self.options.httpx_client, + http_client=http_client, ) return self._postgrest @@ -183,17 +195,29 @@ def postgrest(self): @property def storage(self): if self._storage is None: + # Create a copy of httpx_client to prevent base_url mutation across services + http_client = ( + copy.copy(self.options.httpx_client) + if self.options.httpx_client is not None + else None + ) self._storage = self._init_storage_client( storage_url=self.storage_url, headers=self.options.headers, storage_client_timeout=self.options.storage_client_timeout, - http_client=self.options.httpx_client, + http_client=http_client, ) return self._storage @property def functions(self): if self._functions is None: + # Create a copy of httpx_client to prevent base_url mutation across services + http_client = ( + copy.copy(self.options.httpx_client) + if self.options.httpx_client is not None + else None + ) self._functions = AsyncFunctionsClient( url=self.functions_url, headers=self.options.headers, @@ -202,7 +226,7 @@ def functions(self): if self.options.httpx_client is None else None ), - http_client=self.options.httpx_client, + http_client=http_client, ) return self._functions diff --git a/src/supabase/src/supabase/_sync/client.py b/src/supabase/src/supabase/_sync/client.py index d9cc28e5..db98798f 100644 --- a/src/supabase/src/supabase/_sync/client.py +++ b/src/supabase/src/supabase/_sync/client.py @@ -76,9 +76,15 @@ def __init__( self.functions_url = f"{supabase_url}/functions/v1" # Instantiate clients. + # Create a copy of client_options with a copied httpx_client for auth + # to prevent base_url mutation across services + auth_options = copy.copy(self.options) + if self.options.httpx_client is not None: + auth_options.httpx_client = copy.copy(self.options.httpx_client) + self.auth = self._init_supabase_auth_client( auth_url=self.auth_url, - client_options=self.options, + client_options=auth_options, ) self.realtime = self._init_realtime_client( realtime_url=self.realtime_url, @@ -169,12 +175,18 @@ def rpc( @property def postgrest(self): if self._postgrest is None: + # Create a copy of httpx_client to prevent base_url mutation across services + http_client = ( + copy.copy(self.options.httpx_client) + if self.options.httpx_client is not None + else None + ) self._postgrest = self._init_postgrest_client( rest_url=self.rest_url, headers=self.options.headers, schema=self.options.schema, timeout=self.options.postgrest_client_timeout, - http_client=self.options.httpx_client, + http_client=http_client, ) return self._postgrest @@ -182,17 +194,29 @@ def postgrest(self): @property def storage(self): if self._storage is None: + # Create a copy of httpx_client to prevent base_url mutation across services + http_client = ( + copy.copy(self.options.httpx_client) + if self.options.httpx_client is not None + else None + ) self._storage = self._init_storage_client( storage_url=self.storage_url, headers=self.options.headers, storage_client_timeout=self.options.storage_client_timeout, - http_client=self.options.httpx_client, + http_client=http_client, ) return self._storage @property def functions(self): if self._functions is None: + # Create a copy of httpx_client to prevent base_url mutation across services + http_client = ( + copy.copy(self.options.httpx_client) + if self.options.httpx_client is not None + else None + ) self._functions = SyncFunctionsClient( url=self.functions_url, headers=self.options.headers, @@ -201,7 +225,7 @@ def functions(self): if self.options.httpx_client is None else None ), - http_client=self.options.httpx_client, + http_client=http_client, ) return self._functions diff --git a/src/supabase/tests/_async/test_client.py b/src/supabase/tests/_async/test_client.py index fc70f382..4b842912 100644 --- a/src/supabase/tests/_async/test_client.py +++ b/src/supabase/tests/_async/test_client.py @@ -239,3 +239,44 @@ async def test_custom_headers_immutable(): assert client1.options.headers.get("x-app-name") == "grapes" assert client1.options.headers.get("x-version") == "1.0" assert client2.options.headers.get("x-app-name") == "apple" + + +async def test_httpx_client_base_url_isolation(): + """Test that shared httpx_client doesn't cause base_url mutation between services. + + This test reproduces the issue where accessing PostgREST after Storage causes + Storage requests to hit the wrong endpoint (404 errors). + + See: https://github.com/supabase/supabase-py/issues/1244 + """ + url = os.environ.get("SUPABASE_TEST_URL") + key = os.environ.get("SUPABASE_TEST_KEY") + + # Create client with shared httpx instance + timeout = Timeout(10.0, read=60.0) + httpx_client = AsyncHttpxClient(timeout=timeout) + options = AsyncClientOptions(httpx_client=httpx_client) + client = await create_async_client(url, key, options) + + # Access storage and capture its base_url + storage = client.storage + storage_base_url = str(storage.session.base_url).rstrip('/') + assert storage_base_url.endswith("/storage/v1"), f"Expected storage base_url to end with '/storage/v1', got {storage_base_url}" + + # Access postgrest (this should NOT mutate storage's base_url) + postgrest = client.postgrest + postgrest_base_url = str(postgrest.session.base_url).rstrip('/') + assert postgrest_base_url.endswith("/rest/v1"), f"Expected postgrest base_url to end with '/rest/v1', got {postgrest_base_url}" + + # Verify storage still has the correct base_url + storage_base_url_after = str(storage.session.base_url).rstrip('/') + assert storage_base_url_after.endswith("/storage/v1"), f"Storage base_url was mutated! Expected '/storage/v1', got {storage_base_url_after}" + + # Access functions (should also not mutate other services) + functions = client.functions + functions_base_url = str(functions._client.base_url).rstrip('/') + assert functions_base_url.endswith("/functions/v1"), f"Expected functions base_url to end with '/functions/v1', got {functions_base_url}" + + # Final verification: all services should still have their correct base_urls + assert str(storage.session.base_url).rstrip('/').endswith("/storage/v1"), "Storage base_url was mutated after accessing functions" + assert str(postgrest.session.base_url).rstrip('/').endswith("/rest/v1"), "PostgREST base_url was mutated after accessing functions" diff --git a/src/supabase/tests/_sync/test_client.py b/src/supabase/tests/_sync/test_client.py index d22b44a8..b9463ae8 100644 --- a/src/supabase/tests/_sync/test_client.py +++ b/src/supabase/tests/_sync/test_client.py @@ -239,3 +239,44 @@ def test_custom_headers_immutable(): assert client1.options.headers.get("x-app-name") == "grapes" assert client1.options.headers.get("x-version") == "1.0" assert client2.options.headers.get("x-app-name") == "apple" + + +def test_httpx_client_base_url_isolation(): + """Test that shared httpx_client doesn't cause base_url mutation between services. + + This test reproduces the issue where accessing PostgREST after Storage causes + Storage requests to hit the wrong endpoint (404 errors). + + See: https://github.com/supabase/supabase-py/issues/1244 + """ + url = os.environ.get("SUPABASE_TEST_URL") + key = os.environ.get("SUPABASE_TEST_KEY") + + # Create client with shared httpx instance + timeout = Timeout(10.0, read=60.0) + httpx_client = SyncHttpxClient(timeout=timeout) + options = ClientOptions(httpx_client=httpx_client) + client = create_client(url, key, options) + + # Access storage and capture its base_url + storage = client.storage + storage_base_url = str(storage.session.base_url).rstrip('/') + assert storage_base_url.endswith("/storage/v1"), f"Expected storage base_url to end with '/storage/v1', got {storage_base_url}" + + # Access postgrest (this should NOT mutate storage's base_url) + postgrest = client.postgrest + postgrest_base_url = str(postgrest.session.base_url).rstrip('/') + assert postgrest_base_url.endswith("/rest/v1"), f"Expected postgrest base_url to end with '/rest/v1', got {postgrest_base_url}" + + # Verify storage still has the correct base_url + storage_base_url_after = str(storage.session.base_url).rstrip('/') + assert storage_base_url_after.endswith("/storage/v1"), f"Storage base_url was mutated! Expected '/storage/v1', got {storage_base_url_after}" + + # Access functions (should also not mutate other services) + functions = client.functions + functions_base_url = str(functions._client.base_url).rstrip('/') + assert functions_base_url.endswith("/functions/v1"), f"Expected functions base_url to end with '/functions/v1', got {functions_base_url}" + + # Final verification: all services should still have their correct base_urls + assert str(storage.session.base_url).rstrip('/').endswith("/storage/v1"), "Storage base_url was mutated after accessing functions" + assert str(postgrest.session.base_url).rstrip('/').endswith("/rest/v1"), "PostgREST base_url was mutated after accessing functions"