mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-23 05:35:30 +00:00
refactor(embedding): simplify retry results and error classification (#656)
This commit is contained in:
parent
a4f606c6a8
commit
69e3db8deb
3 changed files with 228 additions and 433 deletions
109
reme/components/embedding_store/_embedding_types.py
Normal file
109
reme/components/embedding_store/_embedding_types.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Internal embedding results and normalized provider errors."""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
_PROVIDER_WIDE_STATUS_CODES = frozenset({401, 403, 404})
|
||||
|
||||
|
||||
# AgentScope's DashScope wrapper serializes the response into a RuntimeError.
|
||||
_STATUS_IN_MESSAGE = re.compile(r'"status_code"\s*:\s*(\d{3})')
|
||||
|
||||
|
||||
_CODE_IN_MESSAGE = re.compile(r'"code"\s*:\s*"([^"]+)"')
|
||||
|
||||
|
||||
_INPUT_DEPENDENT_ERROR_TYPES = frozenset(
|
||||
{
|
||||
"ContextLengthError",
|
||||
"ContextWindowExceededError",
|
||||
"InputLengthError",
|
||||
"TokenLimitError",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
_INPUT_DEPENDENT_MARKERS = (
|
||||
"context length",
|
||||
"context_length",
|
||||
"context window",
|
||||
"input length",
|
||||
"input is too long",
|
||||
"maximum context",
|
||||
"reduce the length",
|
||||
"token limit",
|
||||
"too many tokens",
|
||||
"too long",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _EmbeddingResult:
|
||||
"""Keep recovered vectors together with the failure state that ends isolation."""
|
||||
|
||||
embeddings: list[list[float] | None] | None = None
|
||||
terminal_failure: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _EmbeddingError:
|
||||
"""Normalize provider error shapes once before applying the retry policy."""
|
||||
|
||||
status: int | None
|
||||
code: str | None
|
||||
text: str
|
||||
error_type: str
|
||||
|
||||
@classmethod
|
||||
def from_exception(cls, error: Exception) -> "_EmbeddingError":
|
||||
"""Read attributes, structured bodies, and serialized DashScope responses."""
|
||||
message = str(error)
|
||||
body = getattr(error, "body", None)
|
||||
status = None
|
||||
for value in (
|
||||
getattr(error, "status_code", None),
|
||||
getattr(error, "code", None),
|
||||
getattr(getattr(error, "response", None), "status_code", None),
|
||||
):
|
||||
# Integer codes are HTTP statuses; strings name business errors.
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
status = value
|
||||
break
|
||||
if status is None and (match := _STATUS_IN_MESSAGE.search(message)):
|
||||
status = int(match.group(1))
|
||||
|
||||
code = getattr(error, "code", None)
|
||||
if not isinstance(code, str) or not code:
|
||||
details = body.get("error", body) if isinstance(body, dict) else None
|
||||
code = details.get("code") if isinstance(details, dict) else None
|
||||
if not isinstance(code, str) or not code:
|
||||
match = _CODE_IN_MESSAGE.search(message)
|
||||
code = match.group(1) if match else None
|
||||
text = f"{message} {body}" if isinstance(body, dict) else message
|
||||
return cls(status, code, text.lower(), type(error).__name__)
|
||||
|
||||
@property
|
||||
def is_insufficient_quota(self) -> bool:
|
||||
"""Quota exhaustion takes precedence over a generic HTTP 429."""
|
||||
return self.code == "insufficient_quota"
|
||||
|
||||
@property
|
||||
def is_rate_limited(self) -> bool:
|
||||
"""Recognize rate limits while preserving the separate quota policy."""
|
||||
return not self.is_insufficient_quota and (self.code == "rate_limit_exceeded" or self.status == 429)
|
||||
|
||||
@property
|
||||
def is_provider_wide(self) -> bool:
|
||||
"""Credentials, missing routes, and server outages cannot be isolated per input."""
|
||||
return (
|
||||
self.status is not None and (self.status in _PROVIDER_WIDE_STATUS_CODES or 500 <= self.status < 600)
|
||||
) or self.error_type in ("AuthenticationError", "PermissionDeniedError", "NotFoundError", "InternalServerError")
|
||||
|
||||
@property
|
||||
def is_input_dependent(self) -> bool:
|
||||
"""Only input-length rejections may split; a 413 always requires a text signal."""
|
||||
if self.status not in (None, 400, 413):
|
||||
return False
|
||||
if self.status != 413 and self.error_type in _INPUT_DEPENDENT_ERROR_TYPES:
|
||||
return True
|
||||
return any(marker in self.text for marker in _INPUT_DEPENDENT_MARKERS)
|
||||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ._embedding_types import _EmbeddingError, _EmbeddingResult
|
||||
from .base_embedding_store import BaseEmbeddingStore
|
||||
from ..component_registry import R
|
||||
from ..as_embedding import BaseAsEmbedding
|
||||
|
|
@ -15,12 +15,8 @@ from ..as_embedding import BaseAsEmbedding
|
|||
Miss = tuple[int, str, str] # (result_index, text, cache_key)
|
||||
_MAX_VECTOR_SPACE_ATTEMPTS = 3
|
||||
|
||||
_PROVIDER_WIDE_STATUS_CODES = frozenset({401, 403, 404})
|
||||
|
||||
# Transport failures raised by the OpenAI SDK and httpx. They do not inherit from the built-in
|
||||
# TimeoutError / ConnectionError / OSError, so the built-in check alone lets them slip into the
|
||||
# generic handler and fan out into per-item requests. Matching on class names (across the MRO,
|
||||
# so subclasses such as APITimeoutError are covered) keeps this module free of SDK imports.
|
||||
# Match SDK/httpx transport exceptions without importing optional providers.
|
||||
_TRANSPORT_FAILURE_TYPES = frozenset(
|
||||
{
|
||||
"APIConnectionError",
|
||||
|
|
@ -36,60 +32,9 @@ _TRANSPORT_FAILURE_TYPES = frozenset(
|
|||
},
|
||||
)
|
||||
|
||||
# Transport errors an SDK retries on its own. A positive numeric retry budget is not evidence:
|
||||
# AgentScope's Ollama model keeps the inherited ``max_retries=3`` next to an empty policy, and
|
||||
# the number never buys a retry. Only a client that both has a budget and retries this class of
|
||||
# error owns the failure; otherwise the store keeps its backoff.
|
||||
# A client retry budget only applies to the transport exceptions it retries.
|
||||
_SDK_TRANSPORT_RETRY_TYPES = frozenset({"APIConnectionError", "APITimeoutError"})
|
||||
|
||||
# The status a provider reports can live in more than one place. ``status_code`` is the
|
||||
# OpenAI-compatible attribute, Google GenAI exposes an integer ``code``, httpx-style wrappers
|
||||
# keep it on ``response``, and AgentScope's DashScope implementation raises a plain
|
||||
# ``RuntimeError`` whose message embeds the structured response, so the status has to be read
|
||||
# back out of the message. A string ``code`` names an error (``insufficient_quota``,
|
||||
# ``rate_limit_exceeded``, ``InvalidApiKey``) and is never a status.
|
||||
_STATUS_IN_MESSAGE = re.compile(r'"status_code"\s*:\s*(\d{3})')
|
||||
|
||||
# The business error code travels the same way the status does: a string ``code`` on the
|
||||
# OpenAI-compatible SDKs, ``error.code`` inside a structured body, and a serialized
|
||||
# ``"code"`` in the message for AgentScope's DashScope ``RuntimeError``. Reading only the
|
||||
# attribute loses the DashScope case and misclassifies a quota error as a rate limit.
|
||||
_CODE_IN_MESSAGE = re.compile(r'"code"\s*:\s*"([^"]+)"')
|
||||
|
||||
# A rejected batch can only be paid back one item at a time when the provider blames an input.
|
||||
# A context-length rejection is the one status an over-long item explains by itself, so it is
|
||||
# the only error allowed to fan out into per-item requests.
|
||||
_INPUT_DEPENDENT_STATUS_CODES = frozenset({400})
|
||||
|
||||
# A 413 is ambiguous. Providers such as Ollama v0.40.3 normalize an embedding input-limit
|
||||
# rejection to 413, but a bare 413 can also describe an unrelated request-body limit that no
|
||||
# single input explains. It therefore only qualifies as input-dependent when the error itself
|
||||
# names the input length, which is why it is checked through the marker list instead of being
|
||||
# accepted outright.
|
||||
_INPUT_DEPENDENT_AMBIGUOUS_STATUS_CODES = frozenset({413})
|
||||
|
||||
_INPUT_DEPENDENT_ERROR_TYPES = frozenset(
|
||||
{
|
||||
"ContextLengthError",
|
||||
"ContextWindowExceededError",
|
||||
"InputLengthError",
|
||||
"TokenLimitError",
|
||||
},
|
||||
)
|
||||
|
||||
_INPUT_DEPENDENT_MARKERS = (
|
||||
"context length",
|
||||
"context_length",
|
||||
"context window",
|
||||
"input length",
|
||||
"input is too long",
|
||||
"maximum context",
|
||||
"reduce the length",
|
||||
"token limit",
|
||||
"too many tokens",
|
||||
"too long",
|
||||
)
|
||||
|
||||
|
||||
@R.register("local")
|
||||
class LocalEmbeddingStore(BaseEmbeddingStore):
|
||||
|
|
@ -238,17 +183,8 @@ class LocalEmbeddingStore(BaseEmbeddingStore):
|
|||
|
||||
async def _compute_batch(self, batch: list[Miss], **kwargs) -> list[tuple[int, str, np.ndarray]]:
|
||||
texts = [text for _, text, _ in batch]
|
||||
# A provider-wide failure can end a request after some of its vectors were already
|
||||
# recovered. Those vectors are still worth keeping, but they must not report the
|
||||
# provider as healthy again: the unhealthy-to-healthy transition is what schedules
|
||||
# missing-vector backfill, so a hidden outage would either trigger it while the
|
||||
# provider is still unavailable or suppress a later genuine recovery.
|
||||
terminal_failure: list[bool] = []
|
||||
embeddings = await self._call_with_retry(
|
||||
texts,
|
||||
terminal_failure=terminal_failure,
|
||||
**kwargs,
|
||||
)
|
||||
result = await self._call_with_retry(texts, **kwargs)
|
||||
embeddings = result.embeddings
|
||||
if not embeddings or len(embeddings) != len(texts):
|
||||
return []
|
||||
out: list[tuple[int, str, np.ndarray]] = []
|
||||
|
|
@ -264,221 +200,91 @@ class LocalEmbeddingStore(BaseEmbeddingStore):
|
|||
if bad_dims:
|
||||
details = ", ".join(f"{count} with dim {dim}" for dim, count in sorted(bad_dims.items()))
|
||||
self.logger.error(f"Embedding dimension mismatch in batch: expected {self.dimensions}; rejected {details}")
|
||||
if out and not terminal_failure:
|
||||
self.is_healthy = True
|
||||
else:
|
||||
self.is_healthy = False
|
||||
# Retain partial vectors without hiding the outage that ended isolation.
|
||||
self.is_healthy = bool(out) and not result.terminal_failure
|
||||
return out
|
||||
|
||||
async def _call_with_retry(
|
||||
self,
|
||||
texts: list[str],
|
||||
*,
|
||||
terminal_failure: list[bool] | None = None,
|
||||
**kwargs,
|
||||
) -> list[list[float] | None] | None:
|
||||
"""Answer a batch, retrying transient failures and isolating input ones.
|
||||
|
||||
Transport failures are provider-wide: no individual input can explain them, so the
|
||||
request is never split per item. Which layer retries them depends on the provider, not
|
||||
on where the request came from: when the embedding model below already retries the
|
||||
transport exceptions it raises (AgentScope's OpenAI model does, and the OpenAI SDK
|
||||
retries each of those attempts), the store answers once and leaves that budget there.
|
||||
A provider whose model has no retry policy -- Ollama inherits an empty retryable tuple
|
||||
and its SDK turns ``httpx.ConnectError`` into a plain ``ConnectionError`` -- gets the
|
||||
store's backoff instead, because otherwise a single dropped connection would cost the
|
||||
vector.
|
||||
|
||||
``terminal_failure`` is an optional out-parameter. When the caller passes a list,
|
||||
it is set to ``[True]`` if this call gave up on a failure that no remaining item
|
||||
can explain (a provider-wide error, exhausted quota, or exhausted transient
|
||||
retries). The per-item isolation loop uses it to stop instead of asking for every
|
||||
remaining item and multiplying the rejected requests, and it forwards the same
|
||||
flag to its caller so a request that recovered some vectors before the failure is
|
||||
still not reported as healthy.
|
||||
"""
|
||||
|
||||
def stop() -> None:
|
||||
"""Record that this failure ends the work for the whole batch."""
|
||||
if terminal_failure is not None:
|
||||
terminal_failure[:] = [True]
|
||||
|
||||
async def _call_with_retry(self, texts: list[str], **kwargs) -> _EmbeddingResult:
|
||||
"""Retry failures owned by the store and isolate input-length rejections."""
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
result = await self.as_embedding(texts, **kwargs)
|
||||
if result and len(result) == len(texts):
|
||||
return result
|
||||
embeddings = await self.as_embedding(texts, **kwargs)
|
||||
if embeddings and len(embeddings) == len(texts):
|
||||
return _EmbeddingResult(embeddings)
|
||||
except Exception as error:
|
||||
if self._is_transport_failure(error):
|
||||
# A connection failure or a timeout describes the endpoint, not any single
|
||||
# item, so this request is never split per item. Whether the store retries
|
||||
# it is a property of the provider: when the model below has its own retry
|
||||
# policy, that layer (and the SDK under it) already answers the failure --
|
||||
# a batch they answer after 12 attempts used to reach the provider 36 times
|
||||
# with this store retrying on top of them, and an isolated item used to
|
||||
# multiply the same budget a third time.
|
||||
# Preserve recovery where the provider has no matching retry policy;
|
||||
# otherwise leave its budget intact instead of adding another loop.
|
||||
if self._provider_owns_transport_retry(error):
|
||||
self.logger.exception(
|
||||
"Embedding request failed: transport error owned by the provider, not splitting",
|
||||
)
|
||||
self.is_healthy = False
|
||||
stop()
|
||||
break
|
||||
if attempt < self.max_retries - 1:
|
||||
await asyncio.sleep(2**attempt)
|
||||
continue
|
||||
self.logger.exception(
|
||||
"Embedding request failed: transport error after exhausting retries",
|
||||
)
|
||||
self.is_healthy = False
|
||||
stop()
|
||||
self.logger.exception("Embedding request failed: transport error after exhausting retries")
|
||||
break
|
||||
if self._is_rate_limited(error):
|
||||
|
||||
details = _EmbeddingError.from_exception(error)
|
||||
if details.is_rate_limited:
|
||||
if attempt < self.max_retries - 1:
|
||||
delay = 2**attempt
|
||||
self.logger.warning(f"Embedding rate limited; retrying in {delay:.1f}s")
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
self.logger.exception("Embedding request failed after exhausting rate-limit retries")
|
||||
self.is_healthy = False
|
||||
stop()
|
||||
return None
|
||||
if (
|
||||
self.quota_retry_delay is not None
|
||||
and self._is_insufficient_quota(error)
|
||||
and attempt < self.max_retries - 1
|
||||
):
|
||||
self.logger.warning(
|
||||
f"Embedding quota exhausted; retrying in {self.quota_retry_delay:.1f}s",
|
||||
)
|
||||
await asyncio.sleep(self.quota_retry_delay)
|
||||
continue
|
||||
if self._is_insufficient_quota(error):
|
||||
# Quota is a service-level failure: no single item is to blame, so splitting the
|
||||
# batch cannot isolate anything and would only multiply the rejected requests.
|
||||
# This also covers the case where the quota retries above are exhausted.
|
||||
self.logger.exception("Embedding request failed: quota exhausted")
|
||||
self.is_healthy = False
|
||||
stop()
|
||||
return None
|
||||
if self._is_provider_wide_failure(error):
|
||||
# Credentials, permissions and server outages describe the endpoint, not any
|
||||
# single item: isolation cannot fix them, so give up on the whole batch
|
||||
# instead of multiplying the rejected requests.
|
||||
#
|
||||
# A 5xx is deliberately *not* retried here. The embedding wrapper and the
|
||||
# provider SDK already retry transient server errors, and every extra layer
|
||||
# that retries multiplies the attempts and the waiting time of the layers
|
||||
# below it -- a batch the SDK answered after 12 attempts became 36 once this
|
||||
# store retried on top of it. Retry ownership therefore stays with those
|
||||
# layers: a caller that wants a longer budget configures it there instead of
|
||||
# stacking another silent loop here. A transport failure is left to them for
|
||||
# the same reason; it only skips the per-item split.
|
||||
self.logger.exception("Embedding request failed: provider-wide error, not splitting")
|
||||
stop()
|
||||
break
|
||||
if self._is_input_dependent_failure(error):
|
||||
# A rejection a single input explains by itself is the one case the per-item
|
||||
# split can resolve: isolating it keeps the remaining vectors usable.
|
||||
if details.is_insufficient_quota:
|
||||
if self.quota_retry_delay is not None and attempt < self.max_retries - 1:
|
||||
self.logger.warning(f"Embedding quota exhausted; retrying in {self.quota_retry_delay:.1f}s")
|
||||
await asyncio.sleep(self.quota_retry_delay)
|
||||
continue
|
||||
self.logger.exception("Embedding request failed: quota exhausted")
|
||||
break
|
||||
if details.is_provider_wide:
|
||||
# Server-error retries belong to the wrapper/SDK, not to item isolation.
|
||||
self.logger.exception("Embedding request failed: provider-wide error, not splitting")
|
||||
break
|
||||
if details.is_input_dependent:
|
||||
self.logger.exception("Embedding request failed: input-dependent error")
|
||||
self.is_healthy = False
|
||||
if len(texts) > 1:
|
||||
return await self._embed_individually(
|
||||
texts,
|
||||
terminal_failure=terminal_failure,
|
||||
**kwargs,
|
||||
)
|
||||
return None
|
||||
# Anything else counts as provider-wide. Splitting cannot recover a vector unless
|
||||
# the failing input is identified, so an unrecognized error is never answered
|
||||
# with one request per item.
|
||||
return await self._embed_individually(texts, **kwargs)
|
||||
return _EmbeddingResult()
|
||||
self.logger.exception("Embedding request failed: unrecognized error, not splitting")
|
||||
stop()
|
||||
break
|
||||
self.is_healthy = False
|
||||
stop()
|
||||
return None
|
||||
return _EmbeddingResult(terminal_failure=True)
|
||||
|
||||
async def _embed_individually(
|
||||
self,
|
||||
texts: list[str],
|
||||
*,
|
||||
terminal_failure: list[bool] | None = None,
|
||||
**kwargs,
|
||||
) -> list[list[float] | None]:
|
||||
"""Retry a rejected batch one item at a time so one bad item cannot drop the rest.
|
||||
|
||||
Provider-side request errors, such as a per-item context-length limit, reject the
|
||||
whole batch and would otherwise cost every vector in it. Isolating each item bounds
|
||||
the damage to the item that actually failed and keeps the remaining vectors usable.
|
||||
Each single-item request goes back through ``_call_with_retry`` so that a rate limit and
|
||||
a transient transport failure still get the retries that layer owns instead of losing
|
||||
the vector; a provider-wide failure ends the work for the whole batch rather than being
|
||||
asked for every remaining item, and a one-item request never splits again.
|
||||
When the loop stops on a provider-wide failure, ``terminal_failure`` is set for the
|
||||
caller so the vectors recovered before it are not read as a healthy provider.
|
||||
"""
|
||||
results: list[list[float] | None] = []
|
||||
async def _embed_individually(self, texts: list[str], **kwargs) -> _EmbeddingResult:
|
||||
"""Keep healthy siblings, stopping when a failure is no longer specific to an input."""
|
||||
embeddings: list[list[float] | None] = []
|
||||
for index, text in enumerate(texts):
|
||||
terminal: list[bool] = []
|
||||
single = await self._call_with_retry(
|
||||
[text],
|
||||
terminal_failure=terminal,
|
||||
**kwargs,
|
||||
)
|
||||
if single:
|
||||
results.append(single[0])
|
||||
result = await self._call_with_retry([text], **kwargs)
|
||||
if result.embeddings:
|
||||
embeddings.append(result.embeddings[0])
|
||||
continue
|
||||
self.logger.error(f"Embedding request failed for a single item (chars={len(text)})")
|
||||
results.append(None)
|
||||
if terminal and terminal[0]:
|
||||
# No remaining item can explain a provider-wide failure, so asking for the
|
||||
# rest would only multiply the rejected requests. Keep the vectors already
|
||||
# recovered and leave the items that were never answered as None. The batch
|
||||
# caller has to learn about it as well: a partial success must not report
|
||||
# the provider as healthy again.
|
||||
if terminal_failure is not None:
|
||||
terminal_failure[:] = [True]
|
||||
embeddings.append(None)
|
||||
if result.terminal_failure:
|
||||
self.logger.error(
|
||||
f"Stopping per-item isolation after {index + 1}/{len(texts)} items: "
|
||||
"the failure is not specific to any input",
|
||||
)
|
||||
results.extend([None] * (len(texts) - index - 1))
|
||||
break
|
||||
return results
|
||||
embeddings.extend([None] * (len(texts) - index - 1))
|
||||
return _EmbeddingResult(embeddings, terminal_failure=True)
|
||||
return _EmbeddingResult(embeddings)
|
||||
|
||||
@staticmethod
|
||||
def _is_transport_failure(error: Exception) -> bool:
|
||||
"""Return whether an error describes a transport failure rather than a rejected input.
|
||||
|
||||
Covers the built-in socket errors as well as the provider/httpx exceptions that describe
|
||||
a connection failure or a timeout. Transport failures are provider-wide: no individual
|
||||
input can be blamed, so the request is never split per item. Who retries them is decided
|
||||
per provider by ``_provider_owns_transport_retry`` rather than per call site.
|
||||
"""
|
||||
"""Recognize transport errors that must never trigger per-item isolation."""
|
||||
if isinstance(error, (TimeoutError, ConnectionError, OSError)):
|
||||
return True
|
||||
return any(cls.__name__ in _TRANSPORT_FAILURE_TYPES for cls in type(error).__mro__)
|
||||
|
||||
def _provider_owns_transport_retry(self, error: Exception) -> bool:
|
||||
"""Return whether a lower layer retries *this* transport failure.
|
||||
|
||||
Retry ownership is a property of the provider and of the error, not of the call site.
|
||||
A model whose ``_get_retryable_exceptions()`` covers the error owns the retry, and so
|
||||
does an SDK that retries that class of error. Everything else must keep the store's
|
||||
backoff, or one dropped connection costs the vector.
|
||||
|
||||
Two shapes are deliberately not treated as ownership. AgentScope's Ollama embedding
|
||||
model declares an empty policy but keeps the inherited ``max_retries=3``: its loop
|
||||
re-raises every exception outside the empty tuple, so the number never buys a retry.
|
||||
AgentScope's DashScope model declares ``(RuntimeError,)``, which does not cover a
|
||||
connection error raised underneath it. A nonempty policy is therefore checked against
|
||||
the error that actually occurred rather than trusted as a whole.
|
||||
|
||||
A model that exposes no policy at all -- test doubles and custom providers -- is only
|
||||
trusted to an SDK that has a positive retry budget *and* retries this class of
|
||||
transport error. Otherwise the store answers, as the base implementation did.
|
||||
"""
|
||||
"""Require both a matching exception policy and an enabled retry budget."""
|
||||
model = getattr(self.as_embedding, "model", None)
|
||||
if model is None:
|
||||
return False
|
||||
|
|
@ -510,13 +316,7 @@ class LocalEmbeddingStore(BaseEmbeddingStore):
|
|||
|
||||
@staticmethod
|
||||
def _sdk_owns_transport_retry(model, error: Exception) -> bool:
|
||||
"""Return whether the SDK under the model retries this transport error itself.
|
||||
|
||||
The numeric ``max_retries`` only counts once the client also identifies this error as
|
||||
retryable. The OpenAI client retries ``APIConnectionError`` / ``APITimeoutError``;
|
||||
Ollama's client has no retry budget at all, and AgentScope's DashScope path calls the
|
||||
module directly, so neither has a client to consult.
|
||||
"""
|
||||
"""Check the SDK's budget and supported transport errors independently of the model."""
|
||||
if not LocalEmbeddingStore._is_transport_failure(error):
|
||||
return False
|
||||
client = getattr(model, "client", None)
|
||||
|
|
@ -527,118 +327,6 @@ class LocalEmbeddingStore(BaseEmbeddingStore):
|
|||
return False
|
||||
return any(cls.__name__ in _SDK_TRANSPORT_RETRY_TYPES for cls in type(error).__mro__)
|
||||
|
||||
@staticmethod
|
||||
def _http_status(error: Exception) -> int | None:
|
||||
"""Return the HTTP status an exception carries, across the provider SDK shapes.
|
||||
|
||||
``status_code`` covers the OpenAI-compatible SDKs, an integer ``code`` covers Google
|
||||
GenAI, ``response.status_code`` covers httpx-style wrappers, and the structured
|
||||
``status_code`` embedded in the message covers AgentScope's DashScope ``RuntimeError``.
|
||||
Only integers are accepted: a string ``code`` is a provider error name such as
|
||||
``insufficient_quota``, and reading it as a status would misclassify those errors.
|
||||
"""
|
||||
for attribute in ("status_code", "code"):
|
||||
value = getattr(error, attribute, None)
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
return value
|
||||
value = getattr(getattr(error, "response", None), "status_code", None)
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
return value
|
||||
match = _STATUS_IN_MESSAGE.search(str(error))
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
@staticmethod
|
||||
def _is_provider_wide_failure(error: Exception) -> bool:
|
||||
"""Return whether an error describes the provider rather than a single input.
|
||||
|
||||
Authentication, permission, missing model or route and server errors belong to the
|
||||
endpoint, the credentials or the deployment: no individual input can be blamed, so
|
||||
splitting the batch cannot isolate anything and would only multiply the rejected
|
||||
requests. Rate limits and quota exhaustion have their own branches above and never
|
||||
reach this classifier. A context-length ``400`` stays input-dependent on purpose,
|
||||
because that is the one status an over-limit item can explain by itself.
|
||||
"""
|
||||
status = LocalEmbeddingStore._http_status(error)
|
||||
if status is not None and (status in _PROVIDER_WIDE_STATUS_CODES or 500 <= status < 600):
|
||||
return True
|
||||
return type(error).__name__ in (
|
||||
"AuthenticationError",
|
||||
"PermissionDeniedError",
|
||||
"NotFoundError",
|
||||
"InternalServerError",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_input_dependent_failure(error: Exception) -> bool:
|
||||
"""Return whether a single input can be blamed for the rejected request.
|
||||
|
||||
Only a context-length rejection qualifies: it is the one error an over-long item
|
||||
explains by itself, and therefore the only one a per-item retry can resolve. It can
|
||||
arrive as a ``400`` or, from a provider that normalizes the rejection (Ollama reports
|
||||
``413``), as an ambiguous status that is accepted only when the error names the input
|
||||
itself. Anything else stays on the provider-wide path, so a client never answers a
|
||||
service failure with one request per item.
|
||||
"""
|
||||
status = LocalEmbeddingStore._http_status(error)
|
||||
if status is not None and status not in _INPUT_DEPENDENT_STATUS_CODES:
|
||||
if status not in _INPUT_DEPENDENT_AMBIGUOUS_STATUS_CODES:
|
||||
return False
|
||||
# The status alone does not name an input for an ambiguous code, so the error text
|
||||
# has to carry the signal; otherwise this is a request-level rejection.
|
||||
text = LocalEmbeddingStore._error_text(error)
|
||||
return any(marker in text for marker in _INPUT_DEPENDENT_MARKERS)
|
||||
if type(error).__name__ in _INPUT_DEPENDENT_ERROR_TYPES:
|
||||
return True
|
||||
text = LocalEmbeddingStore._error_text(error)
|
||||
return any(marker in text for marker in _INPUT_DEPENDENT_MARKERS)
|
||||
|
||||
@staticmethod
|
||||
def _error_text(error: Exception) -> str:
|
||||
"""Flatten an exception and its OpenAI-compatible body into one lowercase string."""
|
||||
parts = [str(error)]
|
||||
body = getattr(error, "body", None)
|
||||
if isinstance(body, dict):
|
||||
parts.append(str(body))
|
||||
return " ".join(parts).lower()
|
||||
|
||||
@staticmethod
|
||||
def _error_code(error: Exception) -> str | None:
|
||||
"""Return the provider's business error code, across the supported error shapes.
|
||||
|
||||
``code`` covers the OpenAI-compatible SDKs when the provider puts the error name
|
||||
there, ``error.code`` inside ``body`` covers the structured OpenAI payload, and the
|
||||
serialized message covers AgentScope's DashScope ``RuntimeError`` — the same shape
|
||||
``_http_status`` already reads the status back out of. Without the last case a
|
||||
DashScope quota error is indistinguishable from an ordinary 429.
|
||||
"""
|
||||
code = getattr(error, "code", None)
|
||||
if isinstance(code, str) and code:
|
||||
return code
|
||||
body = getattr(error, "body", None)
|
||||
if isinstance(body, dict):
|
||||
details = body.get("error", body)
|
||||
if isinstance(details, dict):
|
||||
inner = details.get("code")
|
||||
if isinstance(inner, str) and inner:
|
||||
return inner
|
||||
match = _CODE_IN_MESSAGE.search(str(error))
|
||||
return match.group(1) if match else None
|
||||
|
||||
@staticmethod
|
||||
def _is_rate_limited(error: Exception) -> bool:
|
||||
"""Recognize an OpenAI-compatible 429 response without importing a provider SDK."""
|
||||
code = LocalEmbeddingStore._error_code(error)
|
||||
if code == "insufficient_quota":
|
||||
return False
|
||||
if code == "rate_limit_exceeded":
|
||||
return True
|
||||
return LocalEmbeddingStore._http_status(error) == 429
|
||||
|
||||
@staticmethod
|
||||
def _is_insufficient_quota(error: Exception) -> bool:
|
||||
"""Recognize OpenAI-compatible quota errors without importing a provider SDK."""
|
||||
return LocalEmbeddingStore._error_code(error) == "insufficient_quota"
|
||||
|
||||
def _validate_dim(self, emb: np.ndarray) -> bool:
|
||||
"""Return whether an embedding exactly matches the configured dimension."""
|
||||
return len(emb) == self.dimensions
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import numpy as np
|
|||
import openai
|
||||
|
||||
from reme.components.as_embedding import DashScopeAsEmbedding, OllamaAsEmbedding, OpenAIAsEmbedding
|
||||
from reme.components.embedding_store._embedding_types import _EmbeddingError
|
||||
from reme.components.embedding_store.base_embedding_store import BaseEmbeddingStore
|
||||
from reme.components.embedding_store.local_embedding_store import LocalEmbeddingStore
|
||||
from reme.schema import EmbNode
|
||||
|
|
@ -566,7 +567,7 @@ def test_insufficient_quota_waits_sixty_seconds_before_retry(monkeypatch):
|
|||
store.as_embedding = embedding
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
result = await store._call_with_retry(["text"])
|
||||
result = (await store._call_with_retry(["text"])).embeddings
|
||||
|
||||
assert result == [[1.0, 0.0]]
|
||||
assert embedding.calls == 2
|
||||
|
|
@ -589,7 +590,7 @@ def test_insufficient_quota_does_not_retry_without_opt_in(monkeypatch):
|
|||
store.as_embedding = embedding
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
result = await store._call_with_retry(["text"])
|
||||
result = (await store._call_with_retry(["text"])).embeddings
|
||||
|
||||
assert result is None
|
||||
assert embedding.calls == 1
|
||||
|
|
@ -612,7 +613,7 @@ def test_rate_limit_retries_with_backoff_without_opt_in(monkeypatch):
|
|||
store.as_embedding = embedding
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
result = await store._call_with_retry(["text"])
|
||||
result = (await store._call_with_retry(["text"])).embeddings
|
||||
|
||||
assert result == [[1.0, 0.0]]
|
||||
assert embedding.calls == 2
|
||||
|
|
@ -634,16 +635,16 @@ def test_is_rate_limited_recognizes_status_code_or_body_and_nothing_else():
|
|||
|
||||
body = {"error": {"code": "rate_limit_exceeded"}}
|
||||
|
||||
assert LocalEmbeddingStore._is_rate_limited(StatusCodeError())
|
||||
assert LocalEmbeddingStore._is_rate_limited(BodyCodeError())
|
||||
assert not LocalEmbeddingStore._is_rate_limited(ValueError("unrelated"))
|
||||
assert _EmbeddingError.from_exception(StatusCodeError()).is_rate_limited
|
||||
assert _EmbeddingError.from_exception(BodyCodeError()).is_rate_limited
|
||||
assert not _EmbeddingError.from_exception(ValueError("unrelated")).is_rate_limited
|
||||
|
||||
|
||||
def test_is_rate_limited_defers_to_insufficient_quota_on_status_code_429():
|
||||
"""An insufficient_quota error carrying status_code=429 is not the generic rate-limit case."""
|
||||
|
||||
assert not LocalEmbeddingStore._is_rate_limited(InsufficientQuotaError("quota exhausted"))
|
||||
assert LocalEmbeddingStore._is_insufficient_quota(InsufficientQuotaError("quota exhausted"))
|
||||
assert not _EmbeddingError.from_exception(InsufficientQuotaError("quota exhausted")).is_rate_limited
|
||||
assert _EmbeddingError.from_exception(InsufficientQuotaError("quota exhausted")).is_insufficient_quota
|
||||
|
||||
|
||||
def test_rate_limit_exhausts_retries_and_reports_unhealthy(monkeypatch):
|
||||
|
|
@ -660,7 +661,7 @@ def test_rate_limit_exhausts_retries_and_reports_unhealthy(monkeypatch):
|
|||
store.as_embedding = embedding
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
result = await store._call_with_retry(["text"])
|
||||
result = (await store._call_with_retry(["text"])).embeddings
|
||||
|
||||
assert result is None
|
||||
assert store.is_healthy is False
|
||||
|
|
@ -1033,7 +1034,7 @@ def test_batch_failure_falls_back_to_per_item_embedding():
|
|||
store = LocalEmbeddingStore(name="t_local_embedding_poison_batch", enable_cache=False)
|
||||
store.as_embedding = provider
|
||||
|
||||
results = await store._call_with_retry(["ok", "x" * 64, "also ok"])
|
||||
results = (await store._call_with_retry(["ok", "x" * 64, "also ok"])).embeddings
|
||||
|
||||
assert results == [[1.0, 0.0], None, [1.0, 0.0]]
|
||||
assert provider.batch_sizes == [3, 1, 1, 1]
|
||||
|
|
@ -1065,7 +1066,9 @@ def test_single_item_failure_still_reports_a_failed_batch():
|
|||
store = LocalEmbeddingStore(name="t_local_embedding_single_poison", enable_cache=False)
|
||||
store.as_embedding = PerItemLimitAsEmbedding()
|
||||
|
||||
assert await store._call_with_retry(["x" * 64]) is None
|
||||
result = await store._call_with_retry(["x" * 64])
|
||||
assert result.embeddings is None
|
||||
assert result.terminal_failure is False
|
||||
|
||||
run(go())
|
||||
|
||||
|
|
@ -1084,7 +1087,7 @@ def test_rate_limited_batch_is_not_split_into_per_item_requests(monkeypatch):
|
|||
store.as_embedding = embedding
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
assert await store._call_with_retry(["a", "b"]) is None
|
||||
assert (await store._call_with_retry(["a", "b"])).embeddings is None
|
||||
assert embedding.calls == 2
|
||||
assert sleeps == [1.0]
|
||||
|
||||
|
|
@ -1105,7 +1108,7 @@ def test_insufficient_quota_batch_is_not_split_into_per_item_requests(monkeypatc
|
|||
store.as_embedding = embedding
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
assert await store._call_with_retry(["a", "b", "c"]) is None
|
||||
assert (await store._call_with_retry(["a", "b", "c"])).embeddings is None
|
||||
assert embedding.batch_sizes == [3]
|
||||
assert store.is_healthy is False
|
||||
assert not sleeps
|
||||
|
|
@ -1131,7 +1134,7 @@ def test_exhausted_quota_retries_still_do_not_split_the_batch(monkeypatch):
|
|||
store.as_embedding = embedding
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
assert await store._call_with_retry(["a", "b"]) is None
|
||||
assert (await store._call_with_retry(["a", "b"])).embeddings is None
|
||||
assert embedding.batch_sizes == [2, 2, 2]
|
||||
assert sleeps == [5.0, 5.0]
|
||||
|
||||
|
|
@ -1163,7 +1166,7 @@ def test_per_item_fallback_keeps_a_rate_limited_vector(monkeypatch):
|
|||
store.as_embedding = provider
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
results = await store._call_with_retry(["ok", "x" * 64, "also ok"])
|
||||
results = (await store._call_with_retry(["ok", "x" * 64, "also ok"])).embeddings
|
||||
|
||||
assert results == [[1.0, 0.0], None, [1.0, 0.0]]
|
||||
assert provider.batch_sizes == [3, 1, 1, 1, 1]
|
||||
|
|
@ -1185,7 +1188,7 @@ def test_provider_wide_credentials_errors_are_not_split_into_per_item_requests()
|
|||
store = LocalEmbeddingStore(name="t_local_embedding_provider_wide", max_retries=3)
|
||||
store.as_embedding = provider
|
||||
|
||||
assert await store._call_with_retry([f"item{i}" for i in range(10)]) is None
|
||||
assert (await store._call_with_retry([f"item{i}" for i in range(10)])).embeddings is None
|
||||
assert provider.batch_sizes == [10]
|
||||
assert store.is_healthy is False
|
||||
|
||||
|
|
@ -1206,7 +1209,7 @@ def test_provider_wide_server_errors_are_not_retried_without_splitting(monkeypat
|
|||
store.as_embedding = provider
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
assert await store._call_with_retry([f"item{i}" for i in range(10)]) is None
|
||||
assert (await store._call_with_retry([f"item{i}" for i in range(10)])).embeddings is None
|
||||
# One request only: the wrapper and the SDK already retry a 5xx, and retrying here
|
||||
# as well multiplied their attempts (12 -> 36 in the reported reproduction).
|
||||
assert provider.batch_sizes == [10]
|
||||
|
|
@ -1237,7 +1240,7 @@ def test_sdk_transport_errors_are_provider_wide_and_not_retried_here(monkeypatch
|
|||
store = LocalEmbeddingStore(name="t_local_embedding_sdk_transport", max_retries=3)
|
||||
store.as_embedding = provider
|
||||
|
||||
assert await store._call_with_retry([f"item{i}" for i in range(10)]) is None
|
||||
assert (await store._call_with_retry([f"item{i}" for i in range(10)])).embeddings is None
|
||||
# One request only: the wrapper and the SDK already retry a transport failure
|
||||
# (4 wrapper attempts, each retried by the SDK), so another loop here only
|
||||
# multiplies them -- a persistent timeout used to cost 36 attempts instead of 12.
|
||||
|
|
@ -1279,7 +1282,7 @@ def test_a_provider_without_a_retry_policy_keeps_the_store_backoff(monkeypatch):
|
|||
store.as_embedding = provider
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
results = await store._call_with_retry(["ok", "x" * 64, "also ok"])
|
||||
results = (await store._call_with_retry(["ok", "x" * 64, "also ok"])).embeddings
|
||||
|
||||
assert results == [[1.0, 0.0], None, [1.0, 0.0]]
|
||||
assert provider.batch_sizes == [3, 1, 1, 1, 1]
|
||||
|
|
@ -1306,7 +1309,7 @@ def test_a_persistent_transport_error_is_not_multiplied_by_the_store(monkeypatch
|
|||
)
|
||||
store.as_embedding = provider
|
||||
|
||||
assert await store._call_with_retry([f"item{i}" for i in range(3)]) is None
|
||||
assert (await store._call_with_retry([f"item{i}" for i in range(3)])).embeddings is None
|
||||
assert provider.calls == 1
|
||||
assert endpoint.requests == 12
|
||||
assert store.is_healthy is False
|
||||
|
|
@ -1335,7 +1338,7 @@ def test_a_transient_transport_error_still_recovers_through_the_sdk(monkeypatch)
|
|||
)
|
||||
store.as_embedding = provider
|
||||
|
||||
results = await store._call_with_retry([f"item{i}" for i in range(3)])
|
||||
results = (await store._call_with_retry([f"item{i}" for i in range(3)])).embeddings
|
||||
|
||||
assert results == [[0.0, 1.0]] * 3
|
||||
assert provider.calls == 1
|
||||
|
|
@ -1374,7 +1377,7 @@ def test_an_isolated_item_still_recovers_through_the_sdk(monkeypatch):
|
|||
)
|
||||
store.as_embedding = provider
|
||||
|
||||
results = await store._call_with_retry(["ok", "x" * 64, "also ok"])
|
||||
results = (await store._call_with_retry(["ok", "x" * 64, "also ok"])).embeddings
|
||||
|
||||
assert results == [[0.0, 1.0], None, [0.0, 1.0]]
|
||||
assert provider.calls == 4
|
||||
|
|
@ -1387,16 +1390,15 @@ def test_provider_wide_statuses_exclude_input_dependent_bad_request():
|
|||
"""404 is provider-wide, but an over-limit 400 must stay input-dependent."""
|
||||
|
||||
async def go():
|
||||
store = LocalEmbeddingStore(name="t_local_embedding_classifier")
|
||||
for error in (
|
||||
AuthenticationError("bad key"),
|
||||
PermissionDeniedError("no access"),
|
||||
NotFoundError("model_not_found"),
|
||||
InternalServerError("boom"),
|
||||
):
|
||||
assert store._is_provider_wide_failure(error) is True
|
||||
assert _EmbeddingError.from_exception(error).is_provider_wide is True
|
||||
over_limit = ContextLengthError("the input length exceeds the context length")
|
||||
assert store._is_provider_wide_failure(over_limit) is False
|
||||
assert _EmbeddingError.from_exception(over_limit).is_provider_wide is False
|
||||
|
||||
run(go())
|
||||
|
||||
|
|
@ -1415,7 +1417,7 @@ def test_provider_wide_not_found_errors_are_not_split(monkeypatch):
|
|||
store.as_embedding = provider
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
assert await store._call_with_retry([f"item{i}" for i in range(10)]) is None
|
||||
assert (await store._call_with_retry([f"item{i}" for i in range(10)])).embeddings is None
|
||||
assert provider.batch_sizes == [10]
|
||||
assert not sleeps
|
||||
|
||||
|
|
@ -1489,26 +1491,22 @@ class DashScopeRuntimeError(RuntimeError):
|
|||
def test_http_status_reads_every_supported_exception_shape():
|
||||
"""Read the status from status_code, an integer code, response.status_code or the message."""
|
||||
|
||||
store = LocalEmbeddingStore(name="t_local_embedding_status_shapes")
|
||||
|
||||
assert store._http_status(AuthenticationError("bad key")) == 401
|
||||
assert store._http_status(NotFoundError("model_not_found")) == 404
|
||||
assert store._http_status(GenAiClientError(401)) == 401
|
||||
assert store._http_status(GenAiServerError(503)) == 503
|
||||
assert store._http_status(WrappedTransportError(404)) == 404
|
||||
assert store._http_status(DashScopeRuntimeError.for_status(401)) == 401
|
||||
assert store._http_status(ValueError("no status here")) is None
|
||||
assert _EmbeddingError.from_exception(AuthenticationError("bad key")).status == 401
|
||||
assert _EmbeddingError.from_exception(NotFoundError("model_not_found")).status == 404
|
||||
assert _EmbeddingError.from_exception(GenAiClientError(401)).status == 401
|
||||
assert _EmbeddingError.from_exception(GenAiServerError(503)).status == 503
|
||||
assert _EmbeddingError.from_exception(WrappedTransportError(404)).status == 404
|
||||
assert _EmbeddingError.from_exception(DashScopeRuntimeError.for_status(401)).status == 401
|
||||
assert _EmbeddingError.from_exception(ValueError("no status here")).status is None
|
||||
|
||||
|
||||
def test_http_status_ignores_a_string_code():
|
||||
"""A string ``code`` names the error; reading it as a status would misclassify quota errors."""
|
||||
|
||||
store = LocalEmbeddingStore(name="t_local_embedding_status_string_code")
|
||||
|
||||
assert store._http_status(StringCodeError("invalid key")) is None
|
||||
assert store._is_insufficient_quota(StringCodeError("invalid key")) is False
|
||||
assert store._http_status(InsufficientQuotaError("quota exhausted")) == 429
|
||||
assert store._is_insufficient_quota(InsufficientQuotaError("quota exhausted")) is True
|
||||
assert _EmbeddingError.from_exception(StringCodeError("invalid key")).status is None
|
||||
assert _EmbeddingError.from_exception(StringCodeError("invalid key")).is_insufficient_quota is False
|
||||
assert _EmbeddingError.from_exception(InsufficientQuotaError("quota exhausted")).status == 429
|
||||
assert _EmbeddingError.from_exception(InsufficientQuotaError("quota exhausted")).is_insufficient_quota is True
|
||||
|
||||
|
||||
def test_google_genai_service_failures_are_not_split_into_per_item_requests(monkeypatch):
|
||||
|
|
@ -1541,7 +1539,7 @@ def test_google_genai_service_failures_are_not_split_into_per_item_requests(monk
|
|||
)
|
||||
store.as_embedding = provider
|
||||
|
||||
assert await store._call_with_retry([f"item{i}" for i in range(10)]) is None
|
||||
assert (await store._call_with_retry([f"item{i}" for i in range(10)])).embeddings is None
|
||||
assert provider.batch_sizes == expected_batches
|
||||
|
||||
run(go())
|
||||
|
|
@ -1572,7 +1570,7 @@ def test_dashscope_runtime_errors_are_not_split_into_per_item_requests(monkeypat
|
|||
)
|
||||
store.as_embedding = provider
|
||||
|
||||
assert await store._call_with_retry([f"item{i}" for i in range(10)]) is None
|
||||
assert (await store._call_with_retry([f"item{i}" for i in range(10)])).embeddings is None
|
||||
assert provider.batch_sizes == expected_batches
|
||||
|
||||
run(go())
|
||||
|
|
@ -1586,7 +1584,7 @@ def test_unrecognized_errors_are_not_split_into_per_item_requests():
|
|||
store = LocalEmbeddingStore(name="t_local_embedding_unrecognized", max_retries=3)
|
||||
store.as_embedding = provider
|
||||
|
||||
assert await store._call_with_retry(["a", "b", "c"]) is None
|
||||
assert (await store._call_with_retry(["a", "b", "c"])).embeddings is None
|
||||
assert provider.batch_sizes == [3]
|
||||
|
||||
run(go())
|
||||
|
|
@ -1595,19 +1593,17 @@ def test_unrecognized_errors_are_not_split_into_per_item_requests():
|
|||
def test_input_dependent_classifier_pins_both_halves_of_the_boundary():
|
||||
"""The isolation must keep working for the one error a single item can explain."""
|
||||
|
||||
store = LocalEmbeddingStore(name="t_local_embedding_input_dependent")
|
||||
|
||||
assert (
|
||||
store._is_input_dependent_failure(
|
||||
_EmbeddingError.from_exception(
|
||||
ContextLengthError("the input length exceeds the context length"),
|
||||
)
|
||||
).is_input_dependent
|
||||
is True
|
||||
)
|
||||
assert store._is_input_dependent_failure(ContextLengthError("too long")) is True
|
||||
assert store._is_input_dependent_failure(AuthenticationError("bad key")) is False
|
||||
assert store._is_input_dependent_failure(InternalServerError("boom")) is False
|
||||
assert store._is_input_dependent_failure(GenAiClientError(400)) is False
|
||||
assert store._is_input_dependent_failure(RuntimeError("provider said no")) is False
|
||||
assert _EmbeddingError.from_exception(ContextLengthError("too long")).is_input_dependent is True
|
||||
assert _EmbeddingError.from_exception(AuthenticationError("bad key")).is_input_dependent is False
|
||||
assert _EmbeddingError.from_exception(InternalServerError("boom")).is_input_dependent is False
|
||||
assert _EmbeddingError.from_exception(GenAiClientError(400)).is_input_dependent is False
|
||||
assert _EmbeddingError.from_exception(RuntimeError("provider said no")).is_input_dependent is False
|
||||
|
||||
|
||||
def test_bad_request_without_an_input_length_signal_is_not_split():
|
||||
|
|
@ -1618,7 +1614,7 @@ def test_bad_request_without_an_input_length_signal_is_not_split():
|
|||
store = LocalEmbeddingStore(name="t_local_embedding_bare_400", max_retries=2)
|
||||
store.as_embedding = provider
|
||||
|
||||
assert await store._call_with_retry(["a", "b", "c"]) is None
|
||||
assert (await store._call_with_retry(["a", "b", "c"])).embeddings is None
|
||||
assert provider.batch_sizes == [3]
|
||||
|
||||
run(go())
|
||||
|
|
@ -1640,9 +1636,10 @@ def test_isolation_stops_when_a_provider_wide_failure_hits():
|
|||
)
|
||||
store.as_embedding = provider
|
||||
|
||||
results = await store._call_with_retry(["ok", "x" * 64, "also ok", "still ok"])
|
||||
result = await store._call_with_retry(["ok", "x" * 64, "also ok", "still ok"])
|
||||
|
||||
assert results == [[1.0, 0.0], None, None, None]
|
||||
assert result.embeddings == [[1.0, 0.0], None, None, None]
|
||||
assert result.terminal_failure is True
|
||||
# One rejected batch, the recovered first item, the over-limit item, then the quota
|
||||
# failure. The fourth item is never requested once the quota failure is seen.
|
||||
assert provider.batch_sizes == [4, 1, 1, 1]
|
||||
|
|
@ -1700,9 +1697,10 @@ def test_isolation_keeps_going_while_only_inputs_fail():
|
|||
)
|
||||
store.as_embedding = provider
|
||||
|
||||
results = await store._call_with_retry(["ok", "x" * 64, "also ok", "still ok"])
|
||||
result = await store._call_with_retry(["ok", "x" * 64, "also ok", "still ok"])
|
||||
|
||||
assert results == [[1.0, 0.0], None, [1.0, 0.0], [1.0, 0.0]]
|
||||
assert result.embeddings == [[1.0, 0.0], None, [1.0, 0.0], [1.0, 0.0]]
|
||||
assert result.terminal_failure is False
|
||||
assert provider.batch_sizes == [4, 1, 1, 1, 1]
|
||||
|
||||
run(go())
|
||||
|
|
@ -1725,8 +1723,8 @@ def test_dashscope_serialized_quota_errors_keep_their_quota_policy(monkeypatch):
|
|||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
error = DashScopeRuntimeError.for_status(429, code="insufficient_quota")
|
||||
assert LocalEmbeddingStore._is_insufficient_quota(error) is True
|
||||
assert LocalEmbeddingStore._is_rate_limited(error) is False
|
||||
assert _EmbeddingError.from_exception(error).is_insufficient_quota is True
|
||||
assert _EmbeddingError.from_exception(error).is_rate_limited is False
|
||||
|
||||
provider = AlwaysProviderWideFailureAsEmbedding(error)
|
||||
store = LocalEmbeddingStore(
|
||||
|
|
@ -1737,7 +1735,7 @@ def test_dashscope_serialized_quota_errors_keep_their_quota_policy(monkeypatch):
|
|||
)
|
||||
store.as_embedding = provider
|
||||
|
||||
assert await store._call_with_retry(["a", "b"]) is None
|
||||
assert (await store._call_with_retry(["a", "b"])).embeddings is None
|
||||
assert provider.batch_sizes == [2, 2, 2]
|
||||
assert sleeps == [7.0, 7.0]
|
||||
|
||||
|
|
@ -1799,7 +1797,7 @@ def test_context_length_rejection_reported_as_http_413_is_isolated():
|
|||
)
|
||||
store.as_embedding = provider
|
||||
|
||||
results = await store._call_with_retry(["ok", "x" * 64, "also ok"])
|
||||
results = (await store._call_with_retry(["ok", "x" * 64, "also ok"])).embeddings
|
||||
|
||||
assert results == [[1.0, 0.0], None, [1.0, 0.0]]
|
||||
assert provider.batch_sizes == [3, 1, 1, 1]
|
||||
|
|
@ -1821,7 +1819,7 @@ def test_bare_http_413_without_an_input_signal_is_not_split():
|
|||
store = LocalEmbeddingStore(name="t_local_embedding_bare_413", max_retries=2)
|
||||
store.as_embedding = provider
|
||||
|
||||
assert await store._call_with_retry(["a", "b", "c"]) is None
|
||||
assert (await store._call_with_retry(["a", "b", "c"])).embeddings is None
|
||||
assert provider.batch_sizes == [3]
|
||||
|
||||
run(go())
|
||||
|
|
@ -1830,18 +1828,18 @@ def test_bare_http_413_without_an_input_signal_is_not_split():
|
|||
def test_input_dependent_classifier_accepts_a_413_only_with_an_input_signal():
|
||||
"""Pin both halves of the 413 boundary, and that the 400 behaviour is unchanged."""
|
||||
|
||||
store = LocalEmbeddingStore(name="t_local_embedding_413_classifier")
|
||||
|
||||
assert (
|
||||
store._is_input_dependent_failure(
|
||||
_EmbeddingError.from_exception(
|
||||
ContextLength413Error("the input length exceeds the context length"),
|
||||
)
|
||||
).is_input_dependent
|
||||
is True
|
||||
)
|
||||
assert store._is_input_dependent_failure(ContextLength413Error("too long")) is True
|
||||
assert store._is_input_dependent_failure(RequestBodyTooLargeError("request entity too large")) is False
|
||||
assert store._is_input_dependent_failure(ContextLengthError("too long")) is True
|
||||
assert store._is_input_dependent_failure(GenAiClientError(400)) is False
|
||||
assert _EmbeddingError.from_exception(ContextLength413Error("too long")).is_input_dependent is True
|
||||
assert (
|
||||
_EmbeddingError.from_exception(RequestBodyTooLargeError("request entity too large")).is_input_dependent is False
|
||||
)
|
||||
assert _EmbeddingError.from_exception(ContextLengthError("too long")).is_input_dependent is True
|
||||
assert _EmbeddingError.from_exception(GenAiClientError(400)).is_input_dependent is False
|
||||
|
||||
|
||||
class _NoRetryPolicyModel:
|
||||
|
|
@ -1904,7 +1902,7 @@ def test_a_provider_with_an_empty_retry_policy_recovers_a_connection_error(monke
|
|||
)
|
||||
store.as_embedding = provider
|
||||
|
||||
results = await store._call_with_retry(["a", "b", "c"])
|
||||
results = (await store._call_with_retry(["a", "b", "c"])).embeddings
|
||||
|
||||
assert results == [[1.0, 0.0]] * 3
|
||||
assert provider.batch_sizes == [3, 3]
|
||||
|
|
@ -1939,7 +1937,7 @@ def test_a_policy_that_does_not_cover_the_error_keeps_the_store_backoff(monkeypa
|
|||
)
|
||||
store.as_embedding = provider
|
||||
|
||||
results = await store._call_with_retry(["a", "b", "c"])
|
||||
results = (await store._call_with_retry(["a", "b", "c"])).embeddings
|
||||
|
||||
assert results == [[1.0, 0.0]] * 3
|
||||
assert provider.batch_sizes == [3, 3]
|
||||
|
|
@ -1976,7 +1974,7 @@ def test_an_isolated_item_does_not_multiply_sdk_transport_retries(monkeypatch):
|
|||
)
|
||||
store.as_embedding = provider
|
||||
|
||||
results = await store._call_with_retry(["ok", "x" * 64, "also ok"])
|
||||
results = (await store._call_with_retry(["ok", "x" * 64, "also ok"])).embeddings
|
||||
|
||||
assert results == [None, None, None]
|
||||
assert provider.calls == 2
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue