From 9ecbcceb54e6bb2b502fd582a1046f40f4c6ac52 Mon Sep 17 00:00:00 2001 From: Md Tanvir Alam Date: Tue, 27 Oct 2026 18:20:42 -0400 Subject: [PATCH] fix(http): send spaces in query parameters as %20, not + httpx encodes a space in a query value as '+', which the Pinecone API reads as a literal plus sign. fetch(), list_paginated() and list_namespaces(prefix=...) therefore found nothing for IDs or namespaces containing a space. Encode params= into the request path so spaces go out as %20, as they did before the move to httpx. Fixes #705 --- pinecone/_internal/http_client.py | 45 +++++- .../test_query_param_space_encoding_705.py | 148 ++++++++++++++++++ 2 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_query_param_space_encoding_705.py diff --git a/pinecone/_internal/http_client.py b/pinecone/_internal/http_client.py index 41c0c2054..31c1652a1 100644 --- a/pinecone/_internal/http_client.py +++ b/pinecone/_internal/http_client.py @@ -352,6 +352,40 @@ def _prepare_path(path: str) -> str: return encoded + suffix +def _fold_params_into_path(path: str, kwargs: dict[str, Any]) -> tuple[str, dict[str, Any]]: + """Move ``params=`` from *kwargs* into the query string of *path*. + + httpx encodes a space in a query value as ``+``, and Pinecone's API reads a + ``+`` as a literal plus sign. A namespace or ID such as ``"my doc"`` therefore + reached the server as ``"my+doc"`` and matched nothing. A space must go on + the wire as ``%20``. + + httpx offers no way to choose that encoding for ``params=``, so the + parameters are encoded here instead and attached to the path, which httpx + leaves as written. The encoding is httpx's own with the one difference: + ``urlencode`` already writes a literal ``+`` as ``%2B``, so every ``+`` in + its output stands for a space and can be rewritten safely. + + Enum members are resolved first (see :func:`_prepare_params`). Without a + ``params`` entry, or with one that encodes to nothing, *path* is returned + as it came in. + """ + kwargs = _prepare_params(kwargs) + if "params" not in kwargs: + return path, kwargs + params = kwargs.pop("params") + if params is None: + return path, kwargs + query = str(httpx.QueryParams(params)).replace("+", "%20") + if not query: + return path, kwargs + head, suffix = _split_off_query_and_fragment(path) + hash_at = suffix.find("#") + existing, fragment = (suffix, "") if hash_at == -1 else (suffix[:hash_at], suffix[hash_at:]) + joiner = "&" if len(existing) > 1 else "?" + return f"{head}{existing.rstrip('?')}{joiner}{query}{fragment}", kwargs + + def _build_headers(config: PineconeConfig, api_version: str) -> dict[str, str]: headers: dict[str, str] = { API_VERSION_HEADER: api_version, @@ -1011,6 +1045,7 @@ def get( self, path: str, timeout: float | httpx.Timeout | None = None, **kwargs: Any ) -> httpx.Response: path = _prepare_path(path) + path, kwargs = _fold_params_into_path(path, kwargs) _log_curl("GET", self._build_url(path), dict(self._headers)) effective_timeout = timeout if timeout is not None else self._config.timeout try: @@ -1084,7 +1119,7 @@ def post( return response # Slow path: caller passed params=, files=, headers=, content=, etc. - kwargs = _prepare_params(_prepare_json_kwargs(kwargs)) + path, kwargs = _fold_params_into_path(path, _prepare_json_kwargs(kwargs)) body = kwargs.get("content") if isinstance(kwargs.get("content"), bytes) else None merged_headers = {**self._headers, **kwargs.get("headers", {})} _log_curl("POST", self._build_url(path), merged_headers, body=body) @@ -1108,6 +1143,7 @@ def put( self, path: str, timeout: float | httpx.Timeout | None = None, **kwargs: Any ) -> httpx.Response: path = _prepare_path(path) + path, kwargs = _fold_params_into_path(path, kwargs) kwargs = _prepare_json_kwargs(kwargs) body = kwargs.get("content") if isinstance(kwargs.get("content"), bytes) else None merged_headers = {**self._headers, **kwargs.get("headers", {})} @@ -1125,6 +1161,7 @@ def patch( self, path: str, timeout: float | httpx.Timeout | None = None, **kwargs: Any ) -> httpx.Response: path = _prepare_path(path) + path, kwargs = _fold_params_into_path(path, kwargs) kwargs = _prepare_json_kwargs(kwargs) body = kwargs.get("content") if isinstance(kwargs.get("content"), bytes) else None merged_headers = {**self._headers, **kwargs.get("headers", {})} @@ -1144,6 +1181,7 @@ def delete( self, path: str, timeout: float | httpx.Timeout | None = None, **kwargs: Any ) -> httpx.Response: path = _prepare_path(path) + path, kwargs = _fold_params_into_path(path, kwargs) _log_curl("DELETE", self._build_url(path), dict(self._headers)) effective_timeout = timeout if timeout is not None else self._config.timeout try: @@ -1245,6 +1283,7 @@ async def get( self, path: str, timeout: float | httpx.Timeout | None = None, **kwargs: Any ) -> httpx.Response: path = _prepare_path(path) + path, kwargs = _fold_params_into_path(path, kwargs) effective_timeout = timeout if timeout is not None else self._config.timeout try: response = await self._ensure_client().get( @@ -1260,6 +1299,7 @@ async def post( self, path: str, timeout: float | httpx.Timeout | None = None, **kwargs: Any ) -> httpx.Response: path = _prepare_path(path) + path, kwargs = _fold_params_into_path(path, kwargs) effective_timeout = timeout if timeout is not None else self._config.timeout try: response = await self._ensure_client().post( @@ -1275,6 +1315,7 @@ async def put( self, path: str, timeout: float | httpx.Timeout | None = None, **kwargs: Any ) -> httpx.Response: path = _prepare_path(path) + path, kwargs = _fold_params_into_path(path, kwargs) effective_timeout = timeout if timeout is not None else self._config.timeout try: response = await self._ensure_client().put( @@ -1290,6 +1331,7 @@ async def patch( self, path: str, timeout: float | httpx.Timeout | None = None, **kwargs: Any ) -> httpx.Response: path = _prepare_path(path) + path, kwargs = _fold_params_into_path(path, kwargs) effective_timeout = timeout if timeout is not None else self._config.timeout try: response = await self._ensure_client().patch( @@ -1305,6 +1347,7 @@ async def delete( self, path: str, timeout: float | httpx.Timeout | None = None, **kwargs: Any ) -> httpx.Response: path = _prepare_path(path) + path, kwargs = _fold_params_into_path(path, kwargs) effective_timeout = timeout if timeout is not None else self._config.timeout try: response = await self._ensure_client().delete( diff --git a/tests/unit/test_query_param_space_encoding_705.py b/tests/unit/test_query_param_space_encoding_705.py new file mode 100644 index 000000000..816bf6371 --- /dev/null +++ b/tests/unit/test_query_param_space_encoding_705.py @@ -0,0 +1,148 @@ +"""A space in a query value must reach the server as ``%20``, not ``+`` (#705). + +httpx encodes a space in a query value as ``+``. Pinecone's API reads a ``+`` as a +literal plus sign, so ``fetch(ids=["doc 42"])`` asked for ``doc+42``, and +``list_paginated(namespace="my ns")`` listed a namespace called ``my+ns``. Nothing +matched and no error was raised. 8.1.2 sent ``%20``; 9.0.0 moved to httpx and lost it. + +The fix is ``_fold_params_into_path`` at the HTTP boundary, so these tests come in +two layers: every method of both HTTP clients that forwards ``params=``, and the +data-plane calls from the report, ``fetch``, ``list_paginated`` and +``list_namespaces(prefix=...)``, on the sync and async lanes. + +Every assertion is on the raw request target, which is what the server parses. +""" + +from __future__ import annotations + +from typing import Any + +import httpx +import pytest +import respx + +from pinecone import AsyncIndex, Index, VectorType +from pinecone._internal.config import PineconeConfig +from pinecone._internal.http_client import AsyncHTTPClient, HTTPClient + +BASE_URL = "https://api.test.pinecone.io" +INDEX_HOST = "test-index-abc1234.svc.us-east1-gcp.pinecone.io" +INDEX_URL = f"https://{INDEX_HOST}" + + +def _sync_http() -> HTTPClient: + return HTTPClient(PineconeConfig(api_key="key", host=BASE_URL), api_version="2026-07") + + +def _async_http() -> AsyncHTTPClient: + return AsyncHTTPClient(PineconeConfig(api_key="key", host=BASE_URL), api_version="2026-07") + + +def _target(route: respx.Route) -> str: + return route.calls.last.request.url.raw_path.decode() + + +class TestHttpBoundary: + @pytest.mark.parametrize("method", ["get", "post", "put", "patch", "delete"]) + @respx.mock + def test_sync_space_is_percent_20(self, method: str) -> None: + route = respx.route(method=method.upper(), url__startswith=BASE_URL).mock( + return_value=httpx.Response(200, json={}) + ) + kwargs: dict[str, Any] = {} if method in ("get", "delete") else {"json": {"x": 1}} + getattr(_sync_http(), method)("/things", params={"namespace": "my ns"}, **kwargs) + assert _target(route) == "/things?namespace=my%20ns" + + @pytest.mark.parametrize("method", ["get", "post", "put", "patch", "delete"]) + @pytest.mark.asyncio + @respx.mock + async def test_async_space_is_percent_20(self, method: str) -> None: + route = respx.route(method=method.upper(), url__startswith=BASE_URL).mock( + return_value=httpx.Response(200, json={}) + ) + kwargs: dict[str, Any] = {} if method in ("get", "delete") else {"json": {"x": 1}} + await getattr(_async_http(), method)("/things", params={"namespace": "my ns"}, **kwargs) + assert _target(route) == "/things?namespace=my%20ns" + + @respx.mock + def test_a_literal_plus_stays_distinct_from_a_space(self) -> None: + route = respx.get(f"{BASE_URL}/things").mock(return_value=httpx.Response(200, json={})) + _sync_http().get("/things", params={"ids": ["a b", "a+b", "a%b"]}) + assert _target(route) == "/things?ids=a%20b&ids=a%2Bb&ids=a%25b" + assert route.calls.last.request.url.params.get_list("ids") == ["a b", "a+b", "a%b"] + + @respx.mock + def test_enum_members_are_still_resolved(self) -> None: + route = respx.get(f"{BASE_URL}/models").mock(return_value=httpx.Response(200, json={})) + _sync_http().get("/models", params={"vector_type": VectorType.DENSE, "q": "a b"}) + assert _target(route) == "/models?vector_type=dense&q=a%20b" + + @respx.mock + def test_params_are_appended_to_a_query_already_in_the_path(self) -> None: + route = respx.get(f"{BASE_URL}/things").mock(return_value=httpx.Response(200, json={})) + _sync_http().get("/things?a=1", params={"b": "x y"}) + assert _target(route) == "/things?a=1&b=x%20y" + + @respx.mock + def test_no_params_leaves_the_path_alone(self) -> None: + route = respx.get(f"{BASE_URL}/things").mock(return_value=httpx.Response(200, json={})) + _sync_http().get("/things") + assert _target(route) == "/things" + + @respx.mock + def test_empty_params_add_no_question_mark(self) -> None: + route = respx.get(f"{BASE_URL}/things").mock(return_value=httpx.Response(200, json={})) + _sync_http().get("/things", params={}) + assert _target(route) == "/things" + + +class TestDataPlaneCalls: + @respx.mock + def test_fetch_sends_spaces_as_percent_20(self) -> None: + route = respx.get(f"{INDEX_URL}/vectors/fetch").mock( + return_value=httpx.Response(200, json={"vectors": {}, "namespace": "my ns"}) + ) + Index(host=INDEX_HOST, api_key="k").fetch(ids=["doc 42", "doc+42"], namespace="my ns") + target = _target(route) + assert "+" not in target.replace("%2B", "") + assert route.calls.last.request.url.params.get_list("ids") == ["doc 42", "doc+42"] + assert "namespace=my%20ns" in target + assert "ids=doc%2042" in target + assert "ids=doc%2B42" in target + + @respx.mock + def test_list_paginated_sends_spaces_as_percent_20(self) -> None: + route = respx.get(f"{INDEX_URL}/vectors/list").mock( + return_value=httpx.Response(200, json={"vectors": [], "namespace": "my ns"}) + ) + Index(host=INDEX_HOST, api_key="k").list_paginated(namespace="my ns", prefix="doc ") + assert "namespace=my%20ns" in _target(route) + assert "prefix=doc%20" in _target(route) + + @respx.mock + def test_list_namespaces_prefix_sends_spaces_as_percent_20(self) -> None: + route = respx.get(f"{INDEX_URL}/namespaces").mock( + return_value=httpx.Response(200, json={"namespaces": [], "total_count": 0}) + ) + list(Index(host=INDEX_HOST, api_key="k").list_namespaces(prefix="my ns")) + assert "prefix=my%20ns" in _target(route) + + @pytest.mark.asyncio + @respx.mock + async def test_async_fetch_sends_spaces_as_percent_20(self) -> None: + route = respx.get(f"{INDEX_URL}/vectors/fetch").mock( + return_value=httpx.Response(200, json={"vectors": {}, "namespace": "my ns"}) + ) + await AsyncIndex(host=INDEX_HOST, api_key="k").fetch(ids=["doc 42"], namespace="my ns") + target = _target(route) + assert "ids=doc%2042" in target + assert "namespace=my%20ns" in target + + @pytest.mark.asyncio + @respx.mock + async def test_async_list_paginated_sends_spaces_as_percent_20(self) -> None: + route = respx.get(f"{INDEX_URL}/vectors/list").mock( + return_value=httpx.Response(200, json={"vectors": [], "namespace": "my ns"}) + ) + await AsyncIndex(host=INDEX_HOST, api_key="k").list_paginated(namespace="my ns") + assert "namespace=my%20ns" in _target(route)