Skip to content

Commit 4a37ae9

Browse files
zhanghuiclaude
andcommitted
fix(rerank): back off between retries instead of spinning
All three rerank providers retried 429 and 5xx responses with a bare `continue`, so the entire retry budget was spent within milliseconds of the first rejection. That is useless against a per-minute quota, which is exactly what hosted rerank endpoints enforce: the caller burns three attempts and still fails, while the window it needed to wait out had barely started. Observed while driving the LoCoMo agentic suite against a hosted rerank endpoint — search requests failed outright with RerankServiceError while the endpoint itself was healthy and merely pacing us. Adds `_errors.backoff_sleep()` (exponential with full jitter, capped at 8s) and wires it into the vLLM, DeepInfra and DashScope retry loops. The jitter keeps a batch of concurrent searches from re-colliding after they trip the limit together. No behaviour change when the endpoint is healthy: the sleep only runs on a retryable failure that will be retried. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b4d5205 commit 4a37ae9

5 files changed

Lines changed: 72 additions & 3 deletions

File tree

‎src/everos/component/rerank/_errors.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
"""Shared error construction for HTTP-based rerank providers."""
1+
"""Shared error construction and retry pacing for HTTP rerank providers."""
22

33
from __future__ import annotations
44

5+
import asyncio
6+
import random
7+
58
import httpx
69

710
from everos.core.observability.logging import get_logger
@@ -10,6 +13,26 @@
1013

1114
logger = get_logger(__name__)
1215

16+
_BACKOFF_BASE_SECONDS = 0.5
17+
_BACKOFF_CAP_SECONDS = 8.0
18+
19+
20+
async def backoff_sleep(attempt: int) -> None:
21+
"""Wait before the next retry of a 429 / 5xx rerank request.
22+
23+
Retrying with no delay is useless against a *per-minute* quota — the
24+
whole budget burns in milliseconds and the caller still fails. Hosted
25+
rerank routers enforce exactly that kind of quota, so the retry loop
26+
has to actually wait. Exponential with full jitter, capped, to avoid a
27+
thundering herd when a batch of concurrent searches trips the limit
28+
together.
29+
30+
Args:
31+
attempt: Zero-based index of the attempt that just failed.
32+
"""
33+
delay = min(_BACKOFF_BASE_SECONDS * (2**attempt), _BACKOFF_CAP_SECONDS)
34+
await asyncio.sleep(random.uniform(0, delay))
35+
1336

1437
def upstream_http_error(provider: str, response: httpx.Response) -> RerankServiceError:
1538
"""Log the upstream response body and return a client-safe error.

‎src/everos/component/rerank/dashscope_provider.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747

4848
import httpx
4949

50+
from ._errors import backoff_sleep
5051
from .protocol import RerankError, RerankResult
5152

5253

@@ -160,6 +161,7 @@ async def _score_chunk(
160161
f"DashScope rerank HTTP {response.status_code}: "
161162
f"{response.text[:200]}"
162163
)
164+
await backoff_sleep(attempt)
163165
continue
164166
raise RerankError(
165167
f"DashScope rerank HTTP {response.status_code}: "

‎src/everos/component/rerank/deepinfra_provider.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@
3535

3636
import httpx
3737

38-
from ._errors import retries_exhausted_error, transport_error, upstream_http_error
38+
from ._errors import (
39+
backoff_sleep,
40+
retries_exhausted_error,
41+
transport_error,
42+
upstream_http_error,
43+
)
3944
from .protocol import RerankResult, RerankServiceError
4045

4146
# Qwen3-Reranker chat template. The DeepInfra inference API treats the reranker
@@ -160,6 +165,7 @@ async def _score_chunk(
160165
if response.status_code >= 500 or response.status_code == 429:
161166
if attempt == self._max_retries:
162167
raise upstream_http_error("DeepInfra", response)
168+
await backoff_sleep(attempt)
163169
continue
164170
raise upstream_http_error("DeepInfra", response)
165171

‎src/everos/component/rerank/vllm_provider.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@
4141

4242
import httpx
4343

44-
from ._errors import retries_exhausted_error, transport_error, upstream_http_error
44+
from ._errors import (
45+
backoff_sleep,
46+
retries_exhausted_error,
47+
transport_error,
48+
upstream_http_error,
49+
)
4550
from .protocol import RerankResult, RerankServiceError
4651

4752

@@ -143,6 +148,7 @@ async def _score_chunk(
143148
if response.status_code >= 500 or response.status_code == 429:
144149
if attempt == self._max_retries:
145150
raise upstream_http_error("vLLM", response)
151+
await backoff_sleep(attempt)
146152
continue
147153
raise upstream_http_error("vLLM", response)
148154

‎tests/unit/test_component/test_rerank/test_vllm_provider.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,35 @@ def handler(_req: httpx.Request) -> httpx.Response:
185185
p = VllmRerankProvider(model="m", api_key="", base_url="http://x/v1")
186186
with pytest.raises(RerankServiceError, match="malformed rerank result"):
187187
await p.rerank("q", ["a"])
188+
189+
190+
async def test_429_retry_waits_between_attempts(
191+
monkeypatch: pytest.MonkeyPatch,
192+
) -> None:
193+
"""A per-minute quota is not survivable by spinning — retries must sleep."""
194+
import everos.component.rerank._errors as errmod
195+
196+
slept: list[float] = []
197+
198+
async def fake_sleep(seconds: float) -> None:
199+
slept.append(seconds)
200+
201+
monkeypatch.setattr(errmod.asyncio, "sleep", fake_sleep)
202+
203+
attempts = 0
204+
205+
def handler(_req: httpx.Request) -> httpx.Response:
206+
nonlocal attempts
207+
attempts += 1
208+
if attempts <= 2:
209+
return httpx.Response(429, json={"error": "rate limited"})
210+
return _ok_response([{"index": 0, "relevance_score": 0.7}])
211+
212+
_patch_httpx(monkeypatch, handler)
213+
p = VllmRerankProvider(model="m", api_key="k", base_url="http://x/v1")
214+
out = await p.rerank("q", ["d"])
215+
assert [r.score for r in out] == [0.7]
216+
assert attempts == 3
217+
# One wait per failed attempt, and each wait is a real (non-zero) budget.
218+
assert len(slept) == 2
219+
assert all(s >= 0 for s in slept)

0 commit comments

Comments
 (0)