Skip to content

Commit 750a866

Browse files
committed
fix(index): raise ValidationError for invalid _id field in upsert_records
## Purpose Two silent validation gaps caused confusing HTTP 400 errors from the backend: 1. Records with both `_id` and `id` were silently stripped instead of rejected. 2. Non-string `_id` values (e.g. `{"_id": 123}`) passed SDK validation but returned HTTP 400. ## Solution In the normalization loop of `Index.upsert_records` and `AsyncIndex.upsert_records`: - Changed the silent `del r["id"]` to raise `ValidationError` when both `_id` and `id` are present, matching the backend's `InvalidArgument` behavior. - Added a type check after normalization: raises `ValidationError` if the resolved `_id` is not a `str`, catching integer and None IDs before the network call. Updated unit tests (sync + async) and added integration-level validation tests.
1 parent 3b4d95e commit 750a866

5 files changed

Lines changed: 64 additions & 40 deletions

File tree

‎pinecone/async_client/async_index.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,12 +193,16 @@ async def upsert_records(
193193
import orjson
194194

195195
normalized: list[dict[str, Any]] = []
196-
for record in records:
196+
for i, record in enumerate(records):
197197
r = dict(record) # shallow copy
198198
if "_id" not in r and "id" in r:
199199
r["_id"] = r.pop("id")
200200
elif "_id" in r and "id" in r:
201-
del r["id"] # _id takes precedence; strip the extra key
201+
raise ValidationError(f"Record at index {i} cannot have both '_id' and 'id' fields")
202+
resolved_id = r.get("_id")
203+
if not isinstance(resolved_id, str):
204+
got = type(resolved_id).__name__
205+
raise ValidationError(f"Record at index {i}: '_id' must be a string, got {got!r}")
202206
normalized.append(r)
203207

204208
ndjson_lines = [orjson.dumps(r).decode("utf-8") for r in normalized]

‎pinecone/index/__init__.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -522,12 +522,16 @@ def upsert_records(
522522
import orjson
523523

524524
normalized: list[dict[str, Any]] = []
525-
for record in records:
525+
for i, record in enumerate(records):
526526
r = dict(record) # shallow copy
527527
if "_id" not in r and "id" in r:
528528
r["_id"] = r.pop("id")
529529
elif "_id" in r and "id" in r:
530-
del r["id"] # _id takes precedence; strip the extra key
530+
raise ValidationError(f"Record at index {i} cannot have both '_id' and 'id' fields")
531+
resolved_id = r.get("_id")
532+
if not isinstance(resolved_id, str):
533+
got = type(resolved_id).__name__
534+
raise ValidationError(f"Record at index {i}: '_id' must be a string, got {got!r}")
531535
normalized.append(r)
532536

533537
ndjson_lines = [orjson.dumps(r).decode("utf-8") for r in normalized]

‎tests/integration/test_data_plane.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from pinecone import GrpcIndex, Index, Pinecone
1414
from pinecone.errors import ApiError, ConflictError, PineconeValueError
15+
from pinecone.errors.exceptions import ValidationError
1516
from pinecone.grpc.future import PineconeFuture
1617
from pinecone.models.indexes.specs import ServerlessSpec
1718
from pinecone.models.namespaces.models import ListNamespacesResponse, NamespaceDescription
@@ -2274,3 +2275,22 @@ async def test_async_search_with_sparse_vector() -> None:
22742275
assert body["query"]["vector"] == {"sparse_indices": [10, 20], "sparse_values": [0.5, 0.3]}
22752276
assert isinstance(response.result.hits, list)
22762277
assert response.usage.read_units >= 0
2278+
2279+
2280+
# ---------------------------------------------------------------------------
2281+
# upsert_records — client-side ID validation
2282+
# ---------------------------------------------------------------------------
2283+
2284+
2285+
def test_upsert_records_id_must_be_string() -> None:
2286+
"""upsert_records raises ValidationError when '_id' is not a string."""
2287+
idx = Index(host="my-index.svc.pinecone.io", api_key="test-key")
2288+
with pytest.raises(ValidationError, match="'_id' must be a string"):
2289+
idx.upsert_records(namespace="ns", records=[{"_id": 123, "text": "hello"}])
2290+
2291+
2292+
def test_upsert_records_both_id_fields_rejected() -> None:
2293+
"""upsert_records raises ValidationError when record has both '_id' and 'id' fields."""
2294+
idx = Index(host="my-index.svc.pinecone.io", api_key="test-key")
2295+
with pytest.raises(ValidationError, match="cannot have both '_id' and 'id'"):
2296+
idx.upsert_records(namespace="ns", records=[{"_id": "a", "id": "b", "text": "hello"}])

‎tests/unit/test_async_upsert_records.py‎

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -116,23 +116,22 @@ async def test_async_upsert_records_keyword_only(self) -> None:
116116
with pytest.raises(TypeError):
117117
await idx.upsert_records([{"_id": "r1"}], "ns") # type: ignore[misc]
118118

119-
@respx.mock
120119
@pytest.mark.anyio
121-
async def test_async_upsert_records_both_id_keys_underscore_wins(self) -> None:
122-
"""When both '_id' and 'id' are present, '_id' takes precedence and 'id' is stripped."""
123-
route = respx.post(UPSERT_URL).mock(return_value=httpx.Response(201, content=b""))
120+
async def test_async_upsert_records_both_id_fields_rejected(self) -> None:
121+
"""Records with both '_id' and 'id' fields raise ValidationError."""
124122
idx = _make_async_index()
125-
result = await idx.upsert_records(
126-
namespace="test-ns",
127-
records=[
128-
{"_id": "underscore-id-wins", "id": "plain-id-loses", "text": "both keys test"}
129-
],
130-
)
131-
assert isinstance(result, UpsertRecordsResponse)
132-
assert result.record_count == 1
133-
request = route.calls[0].request
134-
body = request.content.decode("utf-8")
135-
parsed = json.loads(body.strip())
136-
assert parsed["_id"] == "underscore-id-wins"
137-
assert "id" not in parsed
138-
assert parsed["text"] == "both keys test"
123+
with pytest.raises(ValidationError, match="cannot have both '_id' and 'id'"):
124+
await idx.upsert_records(
125+
namespace="test-ns",
126+
records=[{"_id": "a", "id": "b", "text": "hello"}],
127+
)
128+
129+
@pytest.mark.anyio
130+
async def test_async_upsert_records_id_must_be_string(self) -> None:
131+
"""Records where '_id' is not a string raise ValidationError."""
132+
idx = _make_async_index()
133+
with pytest.raises(ValidationError, match="'_id' must be a string"):
134+
await idx.upsert_records(
135+
namespace="test-ns",
136+
records=[{"_id": 123, "text": "hello"}],
137+
)

‎tests/unit/test_upsert_records.py‎

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -139,26 +139,23 @@ def test_upsert_records_mixed_id_formats(self) -> None:
139139
assert parsed_1["_id"] == "r2"
140140
assert "id" not in parsed_1
141141

142-
@respx.mock
143-
def test_upsert_records_both_id_keys_underscore_wins(self) -> None:
144-
"""When both '_id' and 'id' are present, '_id' takes precedence and 'id' is stripped."""
145-
route = respx.post(UPSERT_URL).mock(
146-
return_value=httpx.Response(201),
147-
)
142+
def test_upsert_records_both_id_fields_rejected(self) -> None:
143+
"""Records with both '_id' and 'id' fields raise ValidationError."""
148144
idx = _make_index()
149-
result = idx.upsert_records(
150-
namespace="test-ns",
151-
records=[
152-
{"_id": "underscore-id-wins", "id": "plain-id-loses", "text": "both keys test"}
153-
],
154-
)
145+
with pytest.raises(ValidationError, match="cannot have both '_id' and 'id'"):
146+
idx.upsert_records(
147+
namespace="test-ns",
148+
records=[{"_id": "a", "id": "b", "text": "hello"}],
149+
)
155150

156-
assert result.record_count == 1
157-
body = route.calls.last.request.content.decode("utf-8")
158-
parsed = json.loads(body.strip())
159-
assert parsed["_id"] == "underscore-id-wins"
160-
assert "id" not in parsed
161-
assert parsed["text"] == "both keys test"
151+
def test_upsert_records_id_must_be_string(self) -> None:
152+
"""Records where '_id' is not a string raise ValidationError."""
153+
idx = _make_index()
154+
with pytest.raises(ValidationError, match="'_id' must be a string"):
155+
idx.upsert_records(
156+
namespace="test-ns",
157+
records=[{"_id": 123, "text": "hello"}],
158+
)
162159

163160
def test_upsert_records_keyword_only(self) -> None:
164161
idx = _make_index()

0 commit comments

Comments
 (0)