Skip to content

Commit 8045fa9

Browse files
GoodbyePlanetclaude
andcommitted
feat: Add 10/20/30/40s backoff on 429 for Voyage and OpenAI embedding providers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 02c39cd commit 8045fa9

4 files changed

Lines changed: 67 additions & 8 deletions

File tree

server/embeddings/openai.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import logging
45

56
import httpx
@@ -13,6 +14,7 @@
1314
# OpenAI accepts up to 2048 inputs per request; 128 is conservative and matches
1415
# Voyage's cap so behaviour is uniform across providers.
1516
_BATCH_SIZE = 128
17+
_BACKOFF_DELAYS = [10, 20, 30, 40]
1618

1719
_NATIVE_DIMENSIONS: dict[str, int] = {
1820
"text-embedding-3-large": 3072,
@@ -63,7 +65,18 @@ async def embed_batch(self, texts: list[str]) -> list[list[float]]:
6365
body: dict = {"model": self._model, "input": batch}
6466
if self._dims_override is not None:
6567
body["dimensions"] = self._dims_override
66-
resp = await self._client.post(_API_URL, json=body)
68+
for attempt in range(4):
69+
resp = await self._client.post(_API_URL, json=body)
70+
if resp.status_code != 429:
71+
break
72+
retry_after = float(resp.headers.get("Retry-After", 0))
73+
wait = retry_after if retry_after > 0 else _BACKOFF_DELAYS[attempt]
74+
logger.warning(
75+
"OpenAI rate-limited (429) — retrying in %.0fs (attempt %d/4)",
76+
wait,
77+
attempt + 1,
78+
)
79+
await asyncio.sleep(wait)
6780
resp.raise_for_status()
6881
data = resp.json()
6982
batch_vectors = [item["embedding"] for item in data.get("data", [])]

server/embeddings/voyage.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import logging
45

56
import httpx
@@ -12,6 +13,7 @@
1213
_API_URL = "https://api.voyageai.com/v1/embeddings"
1314
# Voyage caps batch size at 128 inputs per request.
1415
_BATCH_SIZE = 128
16+
_BACKOFF_DELAYS = [10, 20, 30, 40]
1517

1618
# Native output dimensions for known models. Some models (voyage-code-3, voyage-3,
1719
# voyage-3-large) accept an `output_dimension` API parameter to shrink/grow this;
@@ -67,9 +69,7 @@ async def embed_query(self, text: str) -> list[float]:
6769
vectors = await self._embed([text], input_type="query")
6870
return vectors[0] if vectors else []
6971

70-
async def _embed(
71-
self, texts: list[str], input_type: str
72-
) -> list[list[float]]:
72+
async def _embed(self, texts: list[str], input_type: str) -> list[list[float]]:
7373
if not texts:
7474
return []
7575
all_vectors: list[list[float]] = []
@@ -82,7 +82,18 @@ async def _embed(
8282
}
8383
if self._dims_override is not None:
8484
body["output_dimension"] = self._dims_override
85-
resp = await self._client.post(_API_URL, json=body)
85+
for attempt in range(4):
86+
resp = await self._client.post(_API_URL, json=body)
87+
if resp.status_code != 429:
88+
break
89+
retry_after = float(resp.headers.get("Retry-After", 0))
90+
wait = retry_after if retry_after > 0 else _BACKOFF_DELAYS[attempt]
91+
logger.warning(
92+
"Voyage rate-limited (429) — retrying in %.0fs (attempt %d/4)",
93+
wait,
94+
attempt + 1,
95+
)
96+
await asyncio.sleep(wait)
8697
resp.raise_for_status()
8798
data = resp.json()
8899
batch_vectors = [item["embedding"] for item in data.get("data", [])]

tests/embeddings/test_jina.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,7 @@ async def test_embed_batch_chunks_at_32(provider):
5555
@respx.mock
5656
async def test_embed_batch_supports_openai_style_response(provider):
5757
respx.post("http://tei-test:80/embed").mock(
58-
return_value=httpx.Response(
59-
200, json={"data": [{"embedding": [0.5] * 768}]}
60-
)
58+
return_value=httpx.Response(200, json={"data": [{"embedding": [0.5] * 768}]})
6159
)
6260
vectors = await provider.embed_batch(["hello"])
6361
assert vectors == [[0.5] * 768]

tests/embeddings/test_voyage.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import json
45

56
import httpx
@@ -124,3 +125,39 @@ async def test_embed_batch_passes_output_dimension_when_overridden(monkeypatch):
124125

125126
async def test_embed_batch_empty(provider):
126127
assert await provider.embed_batch([]) == []
128+
129+
130+
@respx.mock
131+
async def test_rate_limit_backoff_delays(provider, monkeypatch):
132+
sleep_calls: list[float] = []
133+
134+
async def fake_sleep(seconds: float) -> None:
135+
sleep_calls.append(seconds)
136+
137+
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
138+
respx.post("https://api.voyageai.com/v1/embeddings").mock(
139+
side_effect=[
140+
httpx.Response(429),
141+
httpx.Response(429),
142+
httpx.Response(429),
143+
httpx.Response(200, json=_vectors_response(["a"])),
144+
]
145+
)
146+
await provider.embed_batch(["a"])
147+
assert sleep_calls == [10, 20, 30]
148+
149+
150+
@respx.mock
151+
async def test_rate_limit_all_retries_exhausted_raises(provider, monkeypatch):
152+
sleep_calls: list[float] = []
153+
154+
async def fake_sleep(seconds: float) -> None:
155+
sleep_calls.append(seconds)
156+
157+
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
158+
respx.post("https://api.voyageai.com/v1/embeddings").mock(
159+
return_value=httpx.Response(429)
160+
)
161+
with pytest.raises(httpx.HTTPStatusError):
162+
await provider.embed_batch(["a"])
163+
assert sleep_calls == [10, 20, 30, 40]

0 commit comments

Comments
 (0)