Skip to content

Commit 366bb10

Browse files
committed
test(integration): add opt-in retry smoke test for quota-limited upserts
## Purpose All prior retry validation (DX-0165–DX-0172) uses synthetic transports. A live-API test is needed to catch divergence between our throttle model and actual Pinecone API behavior (e.g., 503 vs 429, missing Retry-After). ## Solution Added `tests/integration/test_retry_smoke.py` gated behind `PINECONE_RETRY_SMOKE=1`. Three tests drive 100K-vector upserts at max_concurrency=64 against real serverless indexes (REST sync, async, gRPC) and assert that: - The operation completes in under 180s - Rate limiting was actually triggered (log-level check) - The AIMD limiter fired at least once (log-level check) Each test gets a dedicated module-scoped serverless index (dim=1536). Also added a README section under Development describing the gate, env vars, expected cost, and when to run it before retry-related releases.
1 parent 56d0e16 commit 366bb10

2 files changed

Lines changed: 232 additions & 0 deletions

File tree

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,34 @@ uv sync
136136
uv run pytest tests/unit/ -x -v
137137
```
138138

139+
#### Retry/throttle smoke tests (opt-in)
140+
141+
A suite of live-API smoke tests verifies that the retry stack and AIMD adaptive concurrency
142+
hold up against real Pinecone rate limits. These are **not** run in normal CI because they
143+
require real credentials, create a live serverless index, and take 1–3 minutes per run.
144+
145+
**Required environment variables:**
146+
147+
| Variable | Description |
148+
|---|---|
149+
| `PINECONE_API_KEY` | A valid Pinecone API key |
150+
| `PINECONE_RETRY_SMOKE` | Set to `1` to enable the smoke tests |
151+
152+
**Running the smoke tests:**
153+
154+
```bash
155+
PINECONE_API_KEY=your-api-key PINECONE_RETRY_SMOKE=1 \
156+
uv run pytest tests/integration/test_retry_smoke.py -x -v -s
157+
```
158+
159+
**Cost:** Each run creates three serverless indexes, upserts ~100K vectors per index, then
160+
deletes all indexes. Total cost is under $3 per run.
161+
162+
**When to run:** Before any release that touches retry logic, HTTP transport, the AIMD
163+
adaptive-concurrency limiter (`pinecone._internal.adaptive`), or the batch-upsert path.
164+
The unit tests mock HTTP responses; this test catches divergence between the synthetic
165+
model and real API behavior (e.g., 503 instead of 429, missing `Retry-After` headers).
166+
139167
### Type checking
140168

141169
```bash
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""Opt-in smoke test that exercises retry behavior against the real Pinecone API.
2+
3+
Gated behind PINECONE_RETRY_SMOKE=1. Creates a serverless index, drives a
4+
high-concurrency upsert until the API rate-limits, and asserts the operation
5+
completes successfully. Costs < $1 per run.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import logging
11+
import os
12+
import time
13+
from collections.abc import Generator
14+
15+
import pytest
16+
17+
from pinecone import AsyncPinecone, Pinecone
18+
from pinecone.models.indexes.specs import ServerlessSpec
19+
from tests.integration.conftest import ensure_index_deleted, unique_name
20+
21+
pytestmark = pytest.mark.skipif(
22+
os.environ.get("PINECONE_RETRY_SMOKE") != "1",
23+
reason="Set PINECONE_RETRY_SMOKE=1 to run live retry smoke tests.",
24+
)
25+
26+
# Expected wall-clock for 100K vectors at batch_size=100, max_concurrency=64:
27+
# Best case (no throttling): ~30s
28+
# With throttling (this test): 60-180s, dominated by AIMD ramp-up and
29+
# server-side quota recovery windows.
30+
# The 180s bound is intentionally generous; tighten on stable infra.
31+
32+
33+
@pytest.fixture(scope="module")
34+
def smoke_index_rest(api_key: str) -> Generator[str, None, None]:
35+
"""Module-scoped serverless index (dim=1536) for REST smoke tests."""
36+
pc = Pinecone(api_key=api_key)
37+
name = unique_name("smoke-rest")
38+
pc.indexes.create(
39+
name=name,
40+
dimension=1536,
41+
metric="cosine",
42+
spec=ServerlessSpec(cloud="aws", region="us-east-1"),
43+
timeout=300,
44+
)
45+
try:
46+
yield name
47+
finally:
48+
ensure_index_deleted(pc, name)
49+
50+
51+
@pytest.fixture(scope="module")
52+
def smoke_index_async(api_key: str) -> Generator[str, None, None]:
53+
"""Module-scoped serverless index (dim=1536) for async smoke tests."""
54+
pc = Pinecone(api_key=api_key)
55+
name = unique_name("smoke-async")
56+
pc.indexes.create(
57+
name=name,
58+
dimension=1536,
59+
metric="cosine",
60+
spec=ServerlessSpec(cloud="aws", region="us-east-1"),
61+
timeout=300,
62+
)
63+
try:
64+
yield name
65+
finally:
66+
ensure_index_deleted(pc, name)
67+
68+
69+
@pytest.fixture(scope="module")
70+
def smoke_index_grpc(api_key: str) -> Generator[str, None, None]:
71+
"""Module-scoped serverless index (dim=1536) for gRPC smoke tests."""
72+
pc = Pinecone(api_key=api_key)
73+
name = unique_name("smoke-grpc")
74+
pc.indexes.create(
75+
name=name,
76+
dimension=1536,
77+
metric="cosine",
78+
spec=ServerlessSpec(cloud="aws", region="us-east-1"),
79+
timeout=300,
80+
)
81+
try:
82+
yield name
83+
finally:
84+
ensure_index_deleted(pc, name)
85+
86+
87+
@pytest.mark.integration
88+
def test_high_concurrency_upsert_under_real_throttling(
89+
client: Pinecone,
90+
smoke_index_rest: str,
91+
caplog: pytest.LogCaptureFixture,
92+
) -> None:
93+
"""Sync REST: 100K vectors at max_concurrency=64 triggers rate limiting and recovers."""
94+
caplog.set_level(logging.DEBUG, logger="pinecone._internal.http_client")
95+
caplog.set_level(logging.DEBUG, logger="pinecone._internal.adaptive")
96+
97+
index = client.index(name=smoke_index_rest)
98+
vectors: list[tuple[str, list[float]]] = [(f"id-{i}", [0.1] * 1536) for i in range(100_000)]
99+
100+
start = time.monotonic()
101+
index.upsert(vectors=vectors, batch_size=100, max_concurrency=64, show_progress=False)
102+
elapsed = time.monotonic() - start
103+
104+
# Operation must complete successfully.
105+
assert index.describe_index_stats().total_vector_count >= 100_000
106+
107+
# Must complete in reasonable wall-clock time (3 minutes; serverless quota
108+
# recovery + AIMD ramp; exact bound is empirical — adjust on first run).
109+
assert elapsed < 180, f"upsert took {elapsed:.1f}s, expected < 180s"
110+
111+
# We must have actually hit the rate limiter (otherwise the test isn't
112+
# exercising the retry path).
113+
throttled = [r for r in caplog.records if "Throttled response" in r.getMessage()]
114+
assert len(throttled) > 0, (
115+
"test did not trigger rate limiting; increase concurrency or batch count"
116+
)
117+
118+
# AIMD must have actually engaged.
119+
decreased = [r for r in caplog.records if "AIMD limiter decreased" in r.getMessage()]
120+
assert len(decreased) > 0, (
121+
"AIMD limiter never decreased; throttling did not trigger adaptive concurrency"
122+
)
123+
124+
125+
@pytest.mark.integration
126+
@pytest.mark.anyio
127+
async def test_async_high_concurrency_upsert_under_real_throttling(
128+
async_client: AsyncPinecone,
129+
smoke_index_async: str,
130+
caplog: pytest.LogCaptureFixture,
131+
) -> None:
132+
"""Async REST: 100K vectors at max_concurrency=64 triggers rate limiting and recovers."""
133+
caplog.set_level(logging.DEBUG, logger="pinecone._internal.http_client")
134+
caplog.set_level(logging.DEBUG, logger="pinecone._internal.adaptive")
135+
136+
index = await async_client.index(name=smoke_index_async)
137+
vectors: list[tuple[str, list[float]]] = [(f"id-{i}", [0.1] * 1536) for i in range(100_000)]
138+
139+
start = time.monotonic()
140+
await index.upsert(vectors=vectors, batch_size=100, max_concurrency=64, show_progress=False)
141+
elapsed = time.monotonic() - start
142+
143+
# Operation must complete successfully.
144+
stats = await index.describe_index_stats()
145+
assert stats.total_vector_count >= 100_000
146+
147+
# Must complete in reasonable wall-clock time (3 minutes; serverless quota
148+
# recovery + AIMD ramp; exact bound is empirical — adjust on first run).
149+
assert elapsed < 180, f"upsert took {elapsed:.1f}s, expected < 180s"
150+
151+
# We must have actually hit the rate limiter (otherwise the test isn't
152+
# exercising the retry path).
153+
throttled = [r for r in caplog.records if "Throttled response" in r.getMessage()]
154+
assert len(throttled) > 0, (
155+
"test did not trigger rate limiting; increase concurrency or batch count"
156+
)
157+
158+
# AIMD must have actually engaged.
159+
decreased = [r for r in caplog.records if "AIMD limiter decreased" in r.getMessage()]
160+
assert len(decreased) > 0, (
161+
"AIMD limiter never decreased; throttling did not trigger adaptive concurrency"
162+
)
163+
164+
165+
@pytest.mark.integration
166+
def test_grpc_high_concurrency_upsert_under_real_throttling(
167+
client: Pinecone,
168+
smoke_index_grpc: str,
169+
caplog: pytest.LogCaptureFixture,
170+
) -> None:
171+
"""gRPC: 100K vectors at max_concurrency=64 triggers rate limiting via the shared AIMD limiter.
172+
173+
Phase C (DX-0160) wired gRPC to the same _AdaptiveLimiterRegistry as REST, so
174+
the AIMD decrease assertion validates end-to-end gRPC throttle propagation.
175+
"""
176+
caplog.set_level(logging.DEBUG, logger="pinecone._internal.http_client")
177+
caplog.set_level(logging.DEBUG, logger="pinecone._internal.adaptive")
178+
179+
index = client.index(name=smoke_index_grpc, grpc=True)
180+
vectors: list[tuple[str, list[float]]] = [(f"id-{i}", [0.1] * 1536) for i in range(100_000)]
181+
182+
start = time.monotonic()
183+
index.upsert(vectors=vectors, batch_size=100, max_concurrency=64, show_progress=False)
184+
elapsed = time.monotonic() - start
185+
186+
# Operation must complete successfully.
187+
assert index.describe_index_stats().total_vector_count >= 100_000
188+
189+
# Must complete in reasonable wall-clock time (3 minutes; serverless quota
190+
# recovery + AIMD ramp; exact bound is empirical — adjust on first run).
191+
assert elapsed < 180, f"upsert took {elapsed:.1f}s, expected < 180s"
192+
193+
# We must have actually hit the rate limiter (otherwise the test isn't
194+
# exercising the retry path).
195+
throttled = [r for r in caplog.records if "Throttled response" in r.getMessage()]
196+
assert len(throttled) > 0, (
197+
"test did not trigger rate limiting; increase concurrency or batch count"
198+
)
199+
200+
# AIMD must have actually engaged (Phase C wired gRPC to the same limiter via DX-0160).
201+
decreased = [r for r in caplog.records if "AIMD limiter decreased" in r.getMessage()]
202+
assert len(decreased) > 0, (
203+
"AIMD limiter never decreased; throttling did not trigger adaptive concurrency"
204+
)

0 commit comments

Comments
 (0)