fix(vector): retry transient embed errors so a pod rollover drops 0 docs
From card 309 (OHR-Bench smoke-test triage): during a backend-pod rollover the
embedding endpoint was briefly unreachable, and openai.APIConnectionError /
ConnectError propagated unretried (the provider only retried 429). Documents
exhausted the 3 in-process retries and were dropped for that scan cycle.
Broaden the provider-level retry to the transient set -- APIConnectionError,
APITimeoutError, 429, and 5xx -- on the existing exponential backoff (2s->60s,
5 attempts), so a few seconds of retry rides through the rollover. Permanent
4xx (auth, bad request) still re-raise immediately. Generalize the shared
_retry helper (retry_on_rate_limit -> retry_on_transient, predicate renamed to
should_retry, accurate log label) with a back-compat alias; Mistral gets 429+5xx
for parity. The production gateway path inherits this via GatewayProvider, which
delegates to the decorated OpenAIProvider methods.
Add astrolabe_vector_ingest_dropped_total{reason}, incremented when a document
exhausts retries, classified (connection|timeout|rate_limit|server|qdrant|other)
by _drop_reason so the embed-drop rate is alertable per cause. Dropped docs are
NOT marked failed, so the next full scan re-picks them (re-queue via scan loop).
Refs: Deck board 12 card 309 (AC #1 no permanently-dropped docs; embed-drop
metric for AC #5).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
457c115ef4
commit
258ee96f4c
@@ -161,6 +161,18 @@ vector_sync_processing_duration_seconds = Histogram(
|
|||||||
buckets=(0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0),
|
buckets=(0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Documents dropped after exhausting in-process indexing retries (the scanner
|
||||||
|
# re-picks them on a later full scan, so this is "dropped for this cycle", not
|
||||||
|
# "lost forever"). Labelled by classified cause so the embed-drop rate from a
|
||||||
|
# transient backend-pod rollover (connection/timeout) is alertable distinctly
|
||||||
|
# from a persistent fault (card 309). astrolabe_ prefix: pipeline metric.
|
||||||
|
vector_ingest_dropped_total = Counter(
|
||||||
|
"astrolabe_vector_ingest_dropped_total",
|
||||||
|
"Documents dropped after exhausting indexing retries, by cause",
|
||||||
|
# reason: connection | timeout | rate_limit | server | qdrant | other
|
||||||
|
["reason"],
|
||||||
|
)
|
||||||
|
|
||||||
vector_sync_queue_size = Gauge(
|
vector_sync_queue_size = Gauge(
|
||||||
"mcp_vector_sync_queue_size",
|
"mcp_vector_sync_queue_size",
|
||||||
"Current number of documents in processing queue",
|
"Current number of documents in processing queue",
|
||||||
@@ -692,6 +704,16 @@ def record_document_parse_failed(reason: str) -> None:
|
|||||||
document_parse_failed_total.labels(reason=reason).inc()
|
document_parse_failed_total.labels(reason=reason).inc()
|
||||||
|
|
||||||
|
|
||||||
|
def record_ingest_dropped(reason: str) -> None:
|
||||||
|
"""Record a document dropped after exhausting in-process indexing retries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reason: ``connection`` | ``timeout`` | ``rate_limit`` | ``server`` |
|
||||||
|
``qdrant`` | ``other`` (classified from the terminal exception).
|
||||||
|
"""
|
||||||
|
vector_ingest_dropped_total.labels(reason=reason).inc()
|
||||||
|
|
||||||
|
|
||||||
def record_document_classification(
|
def record_document_classification(
|
||||||
recommended_tier: str,
|
recommended_tier: str,
|
||||||
flags: set[str],
|
flags: set[str],
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""Shared rate-limit retry helper for provider modules.
|
"""Shared transient-error retry helper for provider modules.
|
||||||
|
|
||||||
OpenAI and Mistral both retry on 429 with the same exponential-backoff curve;
|
OpenAI and Mistral retry transient failures (429 rate limits, plus connection
|
||||||
extracting the loop here keeps the two provider modules thin and lets future
|
drops / timeouts / 5xx for the embedding path) on the same exponential-backoff
|
||||||
providers (Bedrock throttling, etc.) reuse the same primitive.
|
curve; extracting the loop here keeps the two provider modules thin and lets
|
||||||
|
future providers (Bedrock throttling, etc.) reuse the same primitive. The
|
||||||
|
``should_retry`` predicate decides which caught exceptions are transient.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -23,23 +25,26 @@ MAX_RETRY_DELAY = 60.0
|
|||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
def retry_on_rate_limit(
|
def retry_on_transient(
|
||||||
exception_type: type[BaseException],
|
exception_type: type[BaseException] | tuple[type[BaseException], ...],
|
||||||
is_rate_limit: Callable[[BaseException], bool] = lambda _exc: True,
|
should_retry: Callable[[BaseException], bool] = lambda _exc: True,
|
||||||
*,
|
*,
|
||||||
provider_name: str = "provider",
|
provider_name: str = "provider",
|
||||||
|
label: str = "rate limit",
|
||||||
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
|
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
|
||||||
"""Build a decorator that retries on rate-limit exceptions.
|
"""Build a decorator that retries transient exceptions with backoff.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
exception_type: Catch this exception class (e.g. ``openai.RateLimitError``,
|
exception_type: Catch this exception class (or tuple of classes), e.g.
|
||||||
``mistralai.client.errors.SDKError``).
|
``openai.APIError`` or ``mistralai.client.errors.SDKError``.
|
||||||
is_rate_limit: Predicate that decides whether a caught exception is
|
should_retry: Predicate that decides whether a caught exception is
|
||||||
actually a rate-limit (vs. some other error of the same class).
|
transient (and so retryable) vs. a permanent error of the same
|
||||||
Defaults to "always True" — appropriate when ``exception_type`` is
|
class. Defaults to "always True" — appropriate when
|
||||||
already a rate-limit-specific class.
|
``exception_type`` is already transient-specific (e.g. a 429 class).
|
||||||
provider_name: Used in log messages so operators can tell which
|
provider_name: Used in log messages so operators can tell which
|
||||||
provider exhausted retries.
|
provider exhausted retries.
|
||||||
|
label: Short noun for the log message ("rate limit", "transient error")
|
||||||
|
so the line accurately names what was retried.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
||||||
@@ -52,22 +57,27 @@ def retry_on_rate_limit(
|
|||||||
try:
|
try:
|
||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
except exception_type as e:
|
except exception_type as e:
|
||||||
if not is_rate_limit(e):
|
if not should_retry(e):
|
||||||
raise
|
raise
|
||||||
last_error = e
|
last_error = e
|
||||||
if attempt < MAX_RETRIES:
|
if attempt < MAX_RETRIES:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"%s rate limit hit (attempt %d/%d), retrying in %.1fs...",
|
"%s %s (attempt %d/%d): %r; retrying in %.1fs...",
|
||||||
provider_name,
|
provider_name,
|
||||||
|
label,
|
||||||
attempt,
|
attempt,
|
||||||
MAX_RETRIES,
|
MAX_RETRIES,
|
||||||
|
e,
|
||||||
retry_delay,
|
retry_delay,
|
||||||
)
|
)
|
||||||
await anyio.sleep(retry_delay)
|
await anyio.sleep(retry_delay)
|
||||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
"%s rate limit exceeded after %d attempts", provider_name, MAX_RETRIES
|
"%s %s not resolved after %d attempts",
|
||||||
|
provider_name,
|
||||||
|
label,
|
||||||
|
MAX_RETRIES,
|
||||||
)
|
)
|
||||||
if last_error is None: # pragma: no cover — loop above always sets this
|
if last_error is None: # pragma: no cover — loop above always sets this
|
||||||
raise RuntimeError("retry loop exited without capturing an error")
|
raise RuntimeError("retry loop exited without capturing an error")
|
||||||
@@ -76,3 +86,7 @@ def retry_on_rate_limit(
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
# Back-compat alias: the helper was originally rate-limit-specific.
|
||||||
|
retry_on_rate_limit = retry_on_transient
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import logging
|
|||||||
from mistralai.client import Mistral
|
from mistralai.client import Mistral
|
||||||
from mistralai.client.errors import SDKError
|
from mistralai.client.errors import SDKError
|
||||||
|
|
||||||
from ._retry import retry_on_rate_limit
|
from ._retry import retry_on_transient
|
||||||
from .base import Provider
|
from .base import Provider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -30,13 +30,17 @@ BATCH_SIZE = 64
|
|||||||
_NO_EMBEDDING_MODEL_MSG = "Embedding not supported - no embedding_model configured"
|
_NO_EMBEDDING_MODEL_MSG = "Embedding not supported - no embedding_model configured"
|
||||||
|
|
||||||
|
|
||||||
def _is_rate_limit(exc: BaseException) -> bool:
|
def _is_transient(exc: BaseException) -> bool:
|
||||||
"""True only for HTTP 429 SDKErrors."""
|
"""Retry HTTP 429 (rate limit) and 5xx (server/transient) SDKErrors."""
|
||||||
return getattr(exc, "status_code", None) == 429
|
status = getattr(exc, "status_code", None)
|
||||||
|
return status == 429 or (isinstance(status, int) and status >= 500)
|
||||||
|
|
||||||
|
|
||||||
_retry_429 = retry_on_rate_limit(
|
_retry_transient = retry_on_transient(
|
||||||
SDKError, is_rate_limit=_is_rate_limit, provider_name="Mistral"
|
SDKError,
|
||||||
|
should_retry=_is_transient,
|
||||||
|
provider_name="Mistral",
|
||||||
|
label="transient error",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -90,7 +94,7 @@ class MistralProvider(Provider):
|
|||||||
def supports_generation(self) -> bool:
|
def supports_generation(self) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@_retry_429
|
@_retry_transient
|
||||||
async def embed(self, text: str) -> list[float]:
|
async def embed(self, text: str) -> list[float]:
|
||||||
"""Generate an embedding for a single text."""
|
"""Generate an embedding for a single text."""
|
||||||
if not self.supports_embeddings:
|
if not self.supports_embeddings:
|
||||||
@@ -169,7 +173,7 @@ class MistralProvider(Provider):
|
|||||||
|
|
||||||
return all_embeddings, total_tokens
|
return all_embeddings, total_tokens
|
||||||
|
|
||||||
@_retry_429
|
@_retry_transient
|
||||||
async def _embed_batch_request(
|
async def _embed_batch_request(
|
||||||
self, batch: list[str]
|
self, batch: list[str]
|
||||||
) -> tuple[list[list[float]], int]:
|
) -> tuple[list[list[float]], int]:
|
||||||
|
|||||||
@@ -8,16 +8,37 @@ Supports:
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from openai import AsyncOpenAI, RateLimitError
|
from openai import APIConnectionError, APIError, APIStatusError, AsyncOpenAI
|
||||||
|
|
||||||
from ._retry import retry_on_rate_limit
|
from ._retry import retry_on_transient
|
||||||
from .base import Provider
|
from .base import Provider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# OpenAI's RateLimitError is itself a 429-specific class, so the default
|
|
||||||
# is_rate_limit predicate ("always True") matches the previous behavior.
|
def _is_transient(exc: BaseException) -> bool:
|
||||||
_retry_429 = retry_on_rate_limit(RateLimitError, provider_name="OpenAI")
|
"""Whether an OpenAI APIError is transient and worth retrying.
|
||||||
|
|
||||||
|
Covers the failures seen dropping documents during a backend-pod rollover
|
||||||
|
(card 309): ``APIConnectionError`` / ``APITimeoutError`` (brief gateway
|
||||||
|
unreachability) and 429 / 5xx status errors. Permanent 4xx (auth, bad
|
||||||
|
request) are NOT retried — they would fail identically every attempt.
|
||||||
|
"""
|
||||||
|
if isinstance(exc, APIConnectionError): # incl. APITimeoutError
|
||||||
|
return True
|
||||||
|
if isinstance(exc, APIStatusError):
|
||||||
|
return exc.status_code == 429 or exc.status_code >= 500
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Catch the APIError base (parent of both APIConnectionError and APIStatusError)
|
||||||
|
# and let the predicate decide; non-transient errors re-raise immediately.
|
||||||
|
_retry_transient = retry_on_transient(
|
||||||
|
APIError,
|
||||||
|
should_retry=_is_transient,
|
||||||
|
provider_name="OpenAI",
|
||||||
|
label="transient error",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Well-known embedding dimensions for OpenAI models
|
# Well-known embedding dimensions for OpenAI models
|
||||||
@@ -95,7 +116,7 @@ class OpenAIProvider(Provider):
|
|||||||
"""Whether this provider supports text generation."""
|
"""Whether this provider supports text generation."""
|
||||||
return self.generation_model is not None
|
return self.generation_model is not None
|
||||||
|
|
||||||
@_retry_429
|
@_retry_transient
|
||||||
async def embed(self, text: str) -> list[float]:
|
async def embed(self, text: str) -> list[float]:
|
||||||
"""
|
"""
|
||||||
Generate embedding vector for text.
|
Generate embedding vector for text.
|
||||||
@@ -208,7 +229,7 @@ class OpenAIProvider(Provider):
|
|||||||
|
|
||||||
return all_embeddings, total_tokens
|
return all_embeddings, total_tokens
|
||||||
|
|
||||||
@_retry_429
|
@_retry_transient
|
||||||
async def _embed_batch_request(
|
async def _embed_batch_request(
|
||||||
self, batch: list[str]
|
self, batch: list[str]
|
||||||
) -> tuple[list[list[float]], int]:
|
) -> tuple[list[list[float]], int]:
|
||||||
@@ -262,7 +283,7 @@ class OpenAIProvider(Provider):
|
|||||||
)
|
)
|
||||||
return self._dimension
|
return self._dimension
|
||||||
|
|
||||||
@_retry_429
|
@_retry_transient
|
||||||
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
|
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
|
||||||
"""
|
"""
|
||||||
Generate text from a prompt.
|
Generate text from a prompt.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import uuid
|
|||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
|
import httpx
|
||||||
from anyio.abc import TaskStatus
|
from anyio.abc import TaskStatus
|
||||||
from anyio.streams.memory import MemoryObjectReceiveStream
|
from anyio.streams.memory import MemoryObjectReceiveStream
|
||||||
from qdrant_client.models import PointStruct
|
from qdrant_client.models import PointStruct
|
||||||
@@ -23,6 +24,7 @@ from nextcloud_mcp_server.observability.metrics import (
|
|||||||
record_document_parse_failed,
|
record_document_parse_failed,
|
||||||
record_embedding,
|
record_embedding,
|
||||||
record_embedding_tokens,
|
record_embedding_tokens,
|
||||||
|
record_ingest_dropped,
|
||||||
record_qdrant_operation,
|
record_qdrant_operation,
|
||||||
record_vector_sync_processing,
|
record_vector_sync_processing,
|
||||||
update_vector_sync_queue_size,
|
update_vector_sync_queue_size,
|
||||||
@@ -56,6 +58,47 @@ logger = logging.getLogger(__name__)
|
|||||||
_ATTR_CHUNK_COUNT = "vector_sync.chunk_count"
|
_ATTR_CHUNK_COUNT = "vector_sync.chunk_count"
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_reason(exc: BaseException) -> str:
|
||||||
|
"""Classify a terminal indexing failure into a metric label.
|
||||||
|
|
||||||
|
Distinguishes the transient backend-pod-rollover causes (connection /
|
||||||
|
timeout — the ones provider-level retry should now ride through, card 309)
|
||||||
|
from persistent faults, so ``astrolabe_vector_ingest_dropped_total`` is
|
||||||
|
alertable per cause. Unwraps a single ExceptionGroup leaf. Best-effort:
|
||||||
|
unknown causes fall back to ``other``.
|
||||||
|
"""
|
||||||
|
# An anyio task group can wrap the real cause; classify the first leaf.
|
||||||
|
if isinstance(exc, BaseExceptionGroup) and exc.exceptions:
|
||||||
|
exc = exc.exceptions[0]
|
||||||
|
|
||||||
|
# httpx transport errors (raised by the OpenAI/gateway client underneath).
|
||||||
|
if isinstance(exc, httpx.TimeoutException):
|
||||||
|
return "timeout"
|
||||||
|
if isinstance(exc, httpx.ConnectError):
|
||||||
|
return "connection"
|
||||||
|
|
||||||
|
# openai.* is always installed (provider dep) but import lazily to keep this
|
||||||
|
# helper cheap and decoupled from a specific SDK version's surface.
|
||||||
|
try:
|
||||||
|
import openai # noqa: PLC0415
|
||||||
|
|
||||||
|
if isinstance(exc, openai.APITimeoutError):
|
||||||
|
return "timeout"
|
||||||
|
if isinstance(exc, openai.APIConnectionError):
|
||||||
|
return "connection"
|
||||||
|
if isinstance(exc, openai.RateLimitError):
|
||||||
|
return "rate_limit"
|
||||||
|
if isinstance(exc, openai.APIStatusError):
|
||||||
|
return "server" if exc.status_code >= 500 else "other"
|
||||||
|
except ImportError: # pragma: no cover — openai is a hard dependency
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Qdrant client errors surface from its own module namespace.
|
||||||
|
if type(exc).__module__.startswith("qdrant_client"):
|
||||||
|
return "qdrant"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
def assign_page_numbers(chunks, page_boundaries):
|
def assign_page_numbers(chunks, page_boundaries):
|
||||||
"""Assign page numbers to chunks based on page boundaries.
|
"""Assign page numbers to chunks based on page boundaries.
|
||||||
|
|
||||||
@@ -455,11 +498,13 @@ async def process_document(
|
|||||||
await anyio.sleep(retry_delay)
|
await anyio.sleep(retry_delay)
|
||||||
retry_delay *= 2 # Exponential backoff
|
retry_delay *= 2 # Exponential backoff
|
||||||
else:
|
else:
|
||||||
|
reason = _drop_reason(e)
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to index %s_%s after %s retries: %s",
|
"Failed to index %s_%s after %s retries (%s): %s",
|
||||||
doc_task.doc_type,
|
doc_task.doc_type,
|
||||||
doc_task.doc_id,
|
doc_task.doc_id,
|
||||||
max_retries,
|
max_retries,
|
||||||
|
reason,
|
||||||
e,
|
e,
|
||||||
extra={
|
extra={
|
||||||
"doc_id": doc_task.doc_id,
|
"doc_id": doc_task.doc_id,
|
||||||
@@ -467,12 +512,18 @@ async def process_document(
|
|||||||
"attempt": max_retries,
|
"attempt": max_retries,
|
||||||
"max_retries": max_retries,
|
"max_retries": max_retries,
|
||||||
"status": "error",
|
"status": "error",
|
||||||
|
"drop_reason": reason,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Record the failed Qdrant upsert. The processing-error
|
# Record the failed Qdrant upsert. The processing-error
|
||||||
# metric is recorded once by the outer handler below, so
|
# metric is recorded once by the outer handler below, so
|
||||||
# exhausted-retry failures aren't double-counted.
|
# exhausted-retry failures aren't double-counted. The
|
||||||
|
# drop counter is labelled by cause so a transient
|
||||||
|
# rollover (connection/timeout) is alertable distinctly.
|
||||||
|
# The document is NOT marked failed, so the next scan
|
||||||
|
# re-picks it (re-queue via the scan loop, card 309).
|
||||||
record_qdrant_operation("upsert", "error")
|
record_qdrant_operation("upsert", "error")
|
||||||
|
record_ingest_dropped(reason)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from nextcloud_mcp_server.providers.mistral import (
|
|||||||
BATCH_SIZE,
|
BATCH_SIZE,
|
||||||
MISTRAL_EMBEDDING_DIMENSIONS,
|
MISTRAL_EMBEDDING_DIMENSIONS,
|
||||||
MistralProvider,
|
MistralProvider,
|
||||||
_is_rate_limit,
|
_is_transient,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -318,14 +318,17 @@ async def test_mistral_embed_with_usage_single(mock_mistral_client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_mistral_is_rate_limit_predicate():
|
def test_mistral_is_transient_predicate():
|
||||||
"""_is_rate_limit returns True only for SDKErrors with status_code == 429."""
|
"""_is_transient retries 429 (rate limit) and 5xx (server/transient) SDKErrors."""
|
||||||
err_429 = MagicMock(spec=SDKError)
|
err_429 = MagicMock(spec=SDKError)
|
||||||
err_429.status_code = 429
|
err_429.status_code = 429
|
||||||
err_500 = MagicMock(spec=SDKError)
|
err_500 = MagicMock(spec=SDKError)
|
||||||
err_500.status_code = 500
|
err_500.status_code = 500
|
||||||
|
err_400 = MagicMock(spec=SDKError)
|
||||||
|
err_400.status_code = 400
|
||||||
|
|
||||||
assert _is_rate_limit(err_429) is True
|
assert _is_transient(err_429) is True
|
||||||
assert _is_rate_limit(err_500) is False
|
assert _is_transient(err_500) is True # broadened to 5xx (card 309)
|
||||||
|
assert _is_transient(err_400) is False # permanent client error
|
||||||
# ValueError has no status_code attr → getattr returns None → False.
|
# ValueError has no status_code attr → getattr returns None → False.
|
||||||
assert _is_rate_limit(ValueError()) is False
|
assert _is_transient(ValueError()) is False
|
||||||
|
|||||||
@@ -330,3 +330,85 @@ async def test_openai_close(mock_openai_client):
|
|||||||
|
|
||||||
await provider.close()
|
await provider.close()
|
||||||
mock_openai_client.close.assert_called_once()
|
mock_openai_client.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# --- transient-error retry (card 309) ----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _req():
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
return httpx.Request("POST", "http://gw/v1/embeddings")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_is_transient_classifies_retryable_errors():
|
||||||
|
"""Connection / timeout / 429 / 5xx are transient; 4xx and others are not."""
|
||||||
|
import httpx
|
||||||
|
from openai import (
|
||||||
|
APIConnectionError,
|
||||||
|
APITimeoutError,
|
||||||
|
BadRequestError,
|
||||||
|
InternalServerError,
|
||||||
|
RateLimitError,
|
||||||
|
)
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.providers.openai import _is_transient
|
||||||
|
|
||||||
|
req = _req()
|
||||||
|
assert _is_transient(APIConnectionError(request=req)) is True
|
||||||
|
assert _is_transient(APITimeoutError(request=req)) is True
|
||||||
|
assert (
|
||||||
|
_is_transient(
|
||||||
|
RateLimitError("rl", response=httpx.Response(429, request=req), body=None)
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
_is_transient(
|
||||||
|
InternalServerError(
|
||||||
|
"boom", response=httpx.Response(500, request=req), body=None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
# Permanent client errors must NOT be retried.
|
||||||
|
assert (
|
||||||
|
_is_transient(
|
||||||
|
BadRequestError("bad", response=httpx.Response(400, request=req), body=None)
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
assert _is_transient(ValueError("unrelated")) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_embed_retries_on_connection_error(mock_openai_client, monkeypatch):
|
||||||
|
"""A transient APIConnectionError (pod rollover) is retried, not dropped."""
|
||||||
|
from openai import APIConnectionError
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.providers import _retry
|
||||||
|
|
||||||
|
monkeypatch.setattr(_retry.anyio, "sleep", AsyncMock(return_value=None))
|
||||||
|
|
||||||
|
mock_embedding_data = MagicMock()
|
||||||
|
mock_embedding_data.embedding = [0.1, 0.2, 0.3]
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.data = [mock_embedding_data]
|
||||||
|
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
async def _flaky(*args, **kwargs):
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] == 1:
|
||||||
|
raise APIConnectionError(request=_req())
|
||||||
|
return mock_response
|
||||||
|
|
||||||
|
mock_openai_client.embeddings.create = _flaky
|
||||||
|
provider = OpenAIProvider(
|
||||||
|
api_key="test-key", embedding_model="text-embedding-3-small"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await provider.embed("hello")
|
||||||
|
assert result == [0.1, 0.2, 0.3]
|
||||||
|
assert calls["n"] == 2 # one failure, one success
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Unit tests for the shared rate-limit retry decorator."""
|
"""Unit tests for the shared transient-error retry decorator."""
|
||||||
|
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
@@ -26,9 +26,7 @@ async def test_retry_succeeds_after_429():
|
|||||||
"""A 429 followed by success returns the success value."""
|
"""A 429 followed by success returns the success value."""
|
||||||
calls = {"n": 0}
|
calls = {"n": 0}
|
||||||
|
|
||||||
@_retry.retry_on_rate_limit(
|
@_retry.retry_on_transient(_FakeError, should_retry=lambda e: e.status_code == 429)
|
||||||
_FakeError, is_rate_limit=lambda e: e.status_code == 429
|
|
||||||
)
|
|
||||||
async def flaky():
|
async def flaky():
|
||||||
calls["n"] += 1
|
calls["n"] += 1
|
||||||
if calls["n"] < 3:
|
if calls["n"] < 3:
|
||||||
@@ -45,9 +43,7 @@ async def test_retry_reraises_non_rate_limit_immediately():
|
|||||||
"""A non-rate-limit error of the same class is re-raised on first hit."""
|
"""A non-rate-limit error of the same class is re-raised on first hit."""
|
||||||
calls = {"n": 0}
|
calls = {"n": 0}
|
||||||
|
|
||||||
@_retry.retry_on_rate_limit(
|
@_retry.retry_on_transient(_FakeError, should_retry=lambda e: e.status_code == 429)
|
||||||
_FakeError, is_rate_limit=lambda e: e.status_code == 429
|
|
||||||
)
|
|
||||||
async def boom():
|
async def boom():
|
||||||
calls["n"] += 1
|
calls["n"] += 1
|
||||||
raise _FakeError(500)
|
raise _FakeError(500)
|
||||||
@@ -62,9 +58,7 @@ async def test_retry_gives_up_after_max_retries():
|
|||||||
"""After MAX_RETRIES failed attempts the last error is re-raised."""
|
"""After MAX_RETRIES failed attempts the last error is re-raised."""
|
||||||
calls = {"n": 0}
|
calls = {"n": 0}
|
||||||
|
|
||||||
@_retry.retry_on_rate_limit(
|
@_retry.retry_on_transient(_FakeError, should_retry=lambda e: e.status_code == 429)
|
||||||
_FakeError, is_rate_limit=lambda e: e.status_code == 429
|
|
||||||
)
|
|
||||||
async def always_429():
|
async def always_429():
|
||||||
calls["n"] += 1
|
calls["n"] += 1
|
||||||
raise _FakeError(429)
|
raise _FakeError(429)
|
||||||
@@ -79,7 +73,7 @@ async def test_retry_default_predicate_treats_all_as_rate_limit():
|
|||||||
"""Default predicate (`lambda _: True`) retries every caught exception."""
|
"""Default predicate (`lambda _: True`) retries every caught exception."""
|
||||||
calls = {"n": 0}
|
calls = {"n": 0}
|
||||||
|
|
||||||
@_retry.retry_on_rate_limit(_FakeError)
|
@_retry.retry_on_transient(_FakeError)
|
||||||
async def fail_once():
|
async def fail_once():
|
||||||
calls["n"] += 1
|
calls["n"] += 1
|
||||||
if calls["n"] < 2:
|
if calls["n"] < 2:
|
||||||
@@ -95,9 +89,37 @@ async def test_retry_default_predicate_treats_all_as_rate_limit():
|
|||||||
async def test_retry_does_not_catch_unrelated_exceptions():
|
async def test_retry_does_not_catch_unrelated_exceptions():
|
||||||
"""Exceptions of a different class bypass the decorator entirely."""
|
"""Exceptions of a different class bypass the decorator entirely."""
|
||||||
|
|
||||||
@_retry.retry_on_rate_limit(_FakeError)
|
@_retry.retry_on_transient(_FakeError)
|
||||||
async def value_error():
|
async def value_error():
|
||||||
raise ValueError("nope")
|
raise ValueError("nope")
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="nope"):
|
with pytest.raises(ValueError, match="nope"):
|
||||||
await value_error()
|
await value_error()
|
||||||
|
|
||||||
|
|
||||||
|
class _ConnError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_retry_accepts_tuple_of_exception_types():
|
||||||
|
"""A tuple of exception classes is caught (the OpenAI transient set shape)."""
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
@_retry.retry_on_transient((_FakeError, _ConnError))
|
||||||
|
async def flaky():
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] == 1:
|
||||||
|
raise _ConnError("dropped")
|
||||||
|
if calls["n"] == 2:
|
||||||
|
raise _FakeError(503)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
assert await flaky() == "ok"
|
||||||
|
assert calls["n"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_retry_on_rate_limit_is_backcompat_alias():
|
||||||
|
"""The old name still resolves to the generalized helper."""
|
||||||
|
assert _retry.retry_on_rate_limit is _retry.retry_on_transient
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Unit tests for the embed-drop classifier (card 309).
|
||||||
|
|
||||||
|
``processor._drop_reason`` maps a terminal indexing failure to a metric label
|
||||||
|
so the transient backend-pod-rollover causes (connection / timeout) are
|
||||||
|
alertable on ``astrolabe_vector_ingest_dropped_total`` distinctly from
|
||||||
|
persistent faults.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.vector import processor
|
||||||
|
|
||||||
|
|
||||||
|
def _req() -> httpx.Request:
|
||||||
|
return httpx.Request("POST", "http://gw/v1/embeddings")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_httpx_connect_and_timeout_classified():
|
||||||
|
assert processor._drop_reason(httpx.ConnectError("refused")) == "connection"
|
||||||
|
assert processor._drop_reason(httpx.ReadTimeout("slow")) == "timeout"
|
||||||
|
assert processor._drop_reason(httpx.ConnectTimeout("slow")) == "timeout"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_openai_errors_classified():
|
||||||
|
from openai import (
|
||||||
|
APIConnectionError,
|
||||||
|
APITimeoutError,
|
||||||
|
InternalServerError,
|
||||||
|
RateLimitError,
|
||||||
|
)
|
||||||
|
|
||||||
|
req = _req()
|
||||||
|
assert processor._drop_reason(APIConnectionError(request=req)) == "connection"
|
||||||
|
assert processor._drop_reason(APITimeoutError(request=req)) == "timeout"
|
||||||
|
assert (
|
||||||
|
processor._drop_reason(
|
||||||
|
RateLimitError("rl", response=httpx.Response(429, request=req), body=None)
|
||||||
|
)
|
||||||
|
== "rate_limit"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
processor._drop_reason(
|
||||||
|
InternalServerError(
|
||||||
|
"boom", response=httpx.Response(503, request=req), body=None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
== "server"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_exception_group_unwraps_to_leaf():
|
||||||
|
group = BaseExceptionGroup(
|
||||||
|
"unhandled errors in a TaskGroup", [httpx.ConnectError("refused")]
|
||||||
|
)
|
||||||
|
assert processor._drop_reason(group) == "connection"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_qdrant_namespace_classified():
|
||||||
|
from qdrant_client.http.exceptions import UnexpectedResponse
|
||||||
|
|
||||||
|
exc = UnexpectedResponse(500, "err", b"", headers=None)
|
||||||
|
assert processor._drop_reason(exc) == "qdrant"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_unknown_error_falls_back_to_other():
|
||||||
|
assert processor._drop_reason(ValueError("nope")) == "other"
|
||||||
Reference in New Issue
Block a user