Skip to content

Commit f8086ff

Browse files
authored
fix: handle empty response body in deserializer (#564) (#623)
## Problem **Deserializer crash (fixes #564):** When using `IndexAsyncio.delete()` against pinecone-local, the server returns an empty response body (`b""`). The deserializer tries `orjson.loads("")`, which fails, and the `except ValueError` fallback assigns the raw empty string as `return_data`. Downstream code then crashes attempting `setattr("", "_response_info", ...)`. **Release process clobbers ruff config:** The version bump regex in `publish-to-pypi.yaml` matches all `version = "..."` occurrences, including ruff's `target-version = "py310"`. Every release overwrites it with the SDK version, breaking the lint CI check. This was previously fixed in 0f60567 but never merged to main. **Docs link test uses wrong version:** `test_docs_links.py` calls the versioned URL template with `__version__` (SDK version, e.g. `"8.1.0"`) instead of `API_VERSION` (`"2025-10"`), producing URLs that 404. ## Solution **Deserializer:** Return `None` early from `Deserializer.deserialize()` when `response.data` is empty or whitespace-only. This is the single shared deserialization codepath for both sync and async clients, so the fix covers all endpoints. Both `api_client.py` and `asyncio_api_client.py` already guard `_response_info` attachment with `if return_data is not None`. **Release process:** Anchor the version bump regex with `^` and `re.MULTILINE` so only the project version at the start of a line is matched. Restore `target-version` to `"py310"`. **Docs link test:** Use `API_VERSION` from `pinecone.core.openapi.db_control` instead of `__version__`, matching what the production code does. ## Key decisions - **Deserializer fix location**: Single fix point in `Deserializer.deserialize()` rather than patching both `api_client.py` and `asyncio_api_client.py` separately. - **Whitespace handling**: The check covers whitespace-only bodies (e.g. `"\n"`, `" "`) in addition to truly empty strings, since these are equally unparseable and could come from non-standard server implementations. - **Return type annotations**: Updated `delete()` signatures on both `Index` and `_IndexAsyncio` to `-> dict[str, Any] | None` to reflect that an empty server response now returns `None` instead of crashing. ## Test plan - [x] Parametrized tests for empty and whitespace-only bodies (`b""`, `b" "`, `b"\n"`, `b" \n "`) — both async and sync clients - [x] Existing behavior preserved: `b"{}"` still deserializes to `dict` - [x] Full unit test suite passes with no regressions - [x] Ruff linting should now pass with restored `target-version = "py310"` - [x] Docs link test should now pass with `API_VERSION` instead of `__version__` <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches the shared OpenAPI deserialization path, changing empty/whitespace HTTP bodies to deserialize as `None`, which could affect any endpoint that previously surfaced an empty string. Other changes are limited to type hints/tests and CI/release tooling. > > **Overview** > Fixes crashes when delete endpoints return an empty/whitespace body by making `Deserializer.deserialize()` return `None` early, and updates `Index.delete()`/`delete_namespace()` (sync + asyncio) return types to `dict | None` to reflect this. > > Adds unit coverage for sync/async `call_api` behavior with empty bodies, fixes the docs-link test to use `API_VERSION` (not the SDK package version), and hardens the PyPI publish workflow’s version-bump regex so it only updates the project `version` line (preventing clobbering Ruff’s `target-version`, which is also restored to `py310`). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 84e0dc8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 4a2e17e commit f8086ff

7 files changed

Lines changed: 112 additions & 14 deletions

File tree

‎.github/workflows/publish-to-pypi.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ jobs:
8383
8484
- name: Bump pyproject.toml version
8585
run: |
86-
python -c "import re; content = open('pyproject.toml').read(); content = re.sub(r'version = \"[^\"]+\"', 'version = \"${{ steps.bump.outputs.version }}\"', content); open('pyproject.toml', 'w').write(content)"
86+
python -c "import re; content = open('pyproject.toml').read(); content = re.sub(r'^version = \"[^\"]+\"', 'version = \"${{ steps.bump.outputs.version }}\"', content, flags=re.MULTILINE); open('pyproject.toml', 'w').write(content)"
8787
8888
- name: Build Python client
8989
run: make package

‎pinecone/db_data/index.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -766,7 +766,7 @@ def delete(
766766
namespace: str | None = None,
767767
filter: FilterTypedDict | None = None,
768768
**kwargs,
769-
) -> dict[str, Any]:
769+
) -> dict[str, Any] | None:
770770
"""Delete vectors from the index, from a single namespace.
771771
772772
The Delete operation deletes vectors from the index, from a single namespace.
@@ -821,7 +821,7 @@ def delete(
821821
),
822822
**self._openapi_kwargs(kwargs),
823823
)
824-
return cast(dict[str, Any], result)
824+
return cast(dict[str, Any] | None, result)
825825

826826
@validate_and_convert_errors
827827
def fetch(self, ids: list[str], namespace: str | None = None, **kwargs) -> FetchResponse:
@@ -1717,15 +1717,15 @@ def describe_namespace(self, namespace: str, **kwargs) -> "NamespaceDescription"
17171717

17181718
@validate_and_convert_errors
17191719
@require_kwargs
1720-
def delete_namespace(self, namespace: str, **kwargs) -> dict[str, Any]:
1720+
def delete_namespace(self, namespace: str, **kwargs) -> dict[str, Any] | None:
17211721
"""Delete a namespace from an index.
17221722
17231723
Args:
17241724
namespace: The namespace to delete.
17251725
**kwargs: Additional keyword arguments for the API call.
17261726
17271727
Returns:
1728-
dict[str, Any]: Response from the delete operation.
1728+
dict[str, Any] | None: Response from the delete operation.
17291729
17301730
Examples:
17311731
@@ -1738,7 +1738,7 @@ def delete_namespace(self, namespace: str, **kwargs) -> dict[str, Any]:
17381738
from typing import cast
17391739

17401740
result = self.namespace.delete(namespace=namespace, **kwargs)
1741-
return cast(dict[str, Any], result)
1741+
return cast(dict[str, Any] | None, result)
17421742

17431743
@validate_and_convert_errors
17441744
@require_kwargs

‎pinecone/db_data/index_asyncio.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,7 @@ async def delete(
517517
namespace: str | None = None,
518518
filter: FilterTypedDict | None = None,
519519
**kwargs,
520-
) -> dict[str, Any]:
520+
) -> dict[str, Any] | None:
521521
"""
522522
Args:
523523
ids (list[str]): Vector ids to delete [optional]
@@ -595,7 +595,7 @@ async def main():
595595
),
596596
**{k: v for k, v in kwargs.items() if k in _OPENAPI_ENDPOINT_PARAMS},
597597
)
598-
return cast(dict[str, Any], result)
598+
return cast(dict[str, Any] | None, result)
599599

600600
@validate_and_convert_errors
601601
async def fetch(self, ids: list[str], namespace: str | None = None, **kwargs) -> FetchResponse:
@@ -1679,19 +1679,19 @@ async def describe_namespace(self, namespace: str, **kwargs) -> "NamespaceDescri
16791679

16801680
@validate_and_convert_errors
16811681
@require_kwargs
1682-
async def delete_namespace(self, namespace: str, **kwargs) -> dict[str, Any]:
1682+
async def delete_namespace(self, namespace: str, **kwargs) -> dict[str, Any] | None:
16831683
"""Delete a namespace from an index.
16841684
16851685
Args:
16861686
namespace (str): The namespace to delete
16871687
16881688
Returns:
1689-
dict[str, Any]: Response from the delete operation
1689+
dict[str, Any] | None: Response from the delete operation
16901690
"""
16911691
from typing import cast
16921692

16931693
result = await self.namespace.delete(namespace=namespace, **kwargs)
1694-
return cast(dict[str, Any], result)
1694+
return cast(dict[str, Any] | None, result)
16951695

16961696
@validate_and_convert_errors
16971697
@require_kwargs

‎pinecone/openapi_support/deserializer.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ def deserialize(
5353
return deserialize_file(response.data, config, content_disposition=content_disposition)
5454

5555
# fetch data from response object
56+
if not response.data or (isinstance(response.data, str) and not response.data.strip()):
57+
return None
58+
5659
try:
5760
received_data = orjson.loads(response.data)
5861
except ValueError:

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ exclude = [
122122

123123
line-length = 100
124124
indent-width = 4
125-
target-version = "8.1.0"
125+
target-version = "py310"
126126

127127
[tool.ruff.lint]
128128
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Reproduction test for GitHub issue #564.
2+
3+
The async delete path fails when the server returns an empty response body
4+
for the delete endpoint. In the user's report (v8.0.0), this manifested as:
5+
AttributeError: 'str' object has no attribute '_response_info'
6+
7+
The root cause was that the deserializer returned a raw empty string when
8+
the response body couldn't be parsed as JSON, and downstream code tried
9+
to set attributes on that string.
10+
11+
The fix returns None early from the deserializer when response data is
12+
empty or whitespace-only.
13+
"""
14+
15+
import pytest
16+
from unittest.mock import AsyncMock, patch
17+
18+
from pinecone.openapi_support.rest_utils import RESTResponse
19+
from pinecone.openapi_support.asyncio_api_client import AsyncioApiClient
20+
from pinecone.openapi_support.api_client import ApiClient
21+
from pinecone.config.openapi_configuration import Configuration
22+
23+
24+
def _make_config():
25+
return Configuration(host="https://test.pinecone.io", api_key={"ApiKeyAuth": "test-key"})
26+
27+
28+
def _make_response(body: bytes):
29+
return RESTResponse(
30+
status=200, data=body, headers={"content-type": "application/json"}, reason="OK"
31+
)
32+
33+
34+
_CALL_API_KWARGS = dict(
35+
resource_path="/vectors/delete",
36+
method="POST",
37+
path_params={},
38+
query_params=[],
39+
header_params={"Content-Type": "application/json"},
40+
body={"deleteAll": True, "namespace": "test-namespace"},
41+
response_type=(dict,),
42+
auth_settings=["ApiKeyAuth"],
43+
_return_http_data_only=True,
44+
_preload_content=True,
45+
_request_timeout=None,
46+
_host=None,
47+
_check_type=True,
48+
collection_formats={},
49+
)
50+
51+
52+
class TestAsyncioDeleteEmptyBody:
53+
"""Async client: empty/whitespace response bodies should return None."""
54+
55+
@pytest.mark.asyncio
56+
@pytest.mark.parametrize("body", [b"", b" ", b"\n", b" \n "])
57+
async def test_empty_or_whitespace_body_returns_none(self, body):
58+
client = AsyncioApiClient(configuration=_make_config())
59+
try:
60+
with patch.object(
61+
client, "request", new_callable=AsyncMock, return_value=_make_response(body)
62+
):
63+
result = await client.call_api(**_CALL_API_KWARGS)
64+
assert result is None
65+
finally:
66+
await client.close()
67+
68+
@pytest.mark.asyncio
69+
async def test_empty_json_object_succeeds(self):
70+
client = AsyncioApiClient(configuration=_make_config())
71+
try:
72+
with patch.object(
73+
client, "request", new_callable=AsyncMock, return_value=_make_response(b"{}")
74+
):
75+
result = await client.call_api(**_CALL_API_KWARGS)
76+
assert isinstance(result, dict)
77+
finally:
78+
await client.close()
79+
80+
81+
class TestSyncDeleteEmptyBody:
82+
"""Sync client: empty/whitespace response bodies should return None."""
83+
84+
@pytest.mark.parametrize("body", [b"", b" ", b"\n", b" \n "])
85+
def test_empty_or_whitespace_body_returns_none(self, body):
86+
client = ApiClient(configuration=_make_config())
87+
with patch.object(client, "request", return_value=_make_response(body)):
88+
result = client.call_api(**_CALL_API_KWARGS)
89+
assert result is None
90+
91+
def test_empty_json_object_succeeds(self):
92+
client = ApiClient(configuration=_make_config())
93+
with patch.object(client, "request", return_value=_make_response(b"{}")):
94+
result = client.call_api(**_CALL_API_KWARGS)
95+
assert isinstance(result, dict)

‎tests/unit/utils/test_docs_links.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import pytest
22
import requests
33
from pinecone.utils import docslinks
4-
from pinecone import __version__
4+
from pinecone.core.openapi.db_control import API_VERSION
55

66
urls = list(docslinks.values())
77

@@ -12,6 +12,6 @@ def test_valid_links(url):
1212
response = requests.get(url)
1313
assert response.status_code == 200, f"Docs link is invalid: {url}"
1414
else:
15-
versioned_url = url(__version__)
15+
versioned_url = url(API_VERSION)
1616
response = requests.get(versioned_url)
1717
assert response.status_code == 200, f"Docs link is invalid: {versioned_url}"

0 commit comments

Comments
 (0)