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),
|
||||
)
|
||||
|
||||
# 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(
|
||||
"mcp_vector_sync_queue_size",
|
||||
"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()
|
||||
|
||||
|
||||
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(
|
||||
recommended_tier: 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;
|
||||
extracting the loop here keeps the two provider modules thin and lets future
|
||||
providers (Bedrock throttling, etc.) reuse the same primitive.
|
||||
OpenAI and Mistral retry transient failures (429 rate limits, plus connection
|
||||
drops / timeouts / 5xx for the embedding path) on the same exponential-backoff
|
||||
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
|
||||
@@ -23,23 +25,26 @@ MAX_RETRY_DELAY = 60.0
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def retry_on_rate_limit(
|
||||
exception_type: type[BaseException],
|
||||
is_rate_limit: Callable[[BaseException], bool] = lambda _exc: True,
|
||||
def retry_on_transient(
|
||||
exception_type: type[BaseException] | tuple[type[BaseException], ...],
|
||||
should_retry: Callable[[BaseException], bool] = lambda _exc: True,
|
||||
*,
|
||||
provider_name: str = "provider",
|
||||
label: str = "rate limit",
|
||||
) -> 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:
|
||||
exception_type: Catch this exception class (e.g. ``openai.RateLimitError``,
|
||||
``mistralai.client.errors.SDKError``).
|
||||
is_rate_limit: Predicate that decides whether a caught exception is
|
||||
actually a rate-limit (vs. some other error of the same class).
|
||||
Defaults to "always True" — appropriate when ``exception_type`` is
|
||||
already a rate-limit-specific class.
|
||||
exception_type: Catch this exception class (or tuple of classes), e.g.
|
||||
``openai.APIError`` or ``mistralai.client.errors.SDKError``.
|
||||
should_retry: Predicate that decides whether a caught exception is
|
||||
transient (and so retryable) vs. a permanent error of the same
|
||||
class. Defaults to "always True" — appropriate when
|
||||
``exception_type`` is already transient-specific (e.g. a 429 class).
|
||||
provider_name: Used in log messages so operators can tell which
|
||||
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]]:
|
||||
@@ -52,22 +57,27 @@ def retry_on_rate_limit(
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except exception_type as e:
|
||||
if not is_rate_limit(e):
|
||||
if not should_retry(e):
|
||||
raise
|
||||
last_error = e
|
||||
if attempt < MAX_RETRIES:
|
||||
logger.warning(
|
||||
"%s rate limit hit (attempt %d/%d), retrying in %.1fs...",
|
||||
"%s %s (attempt %d/%d): %r; retrying in %.1fs...",
|
||||
provider_name,
|
||||
label,
|
||||
attempt,
|
||||
MAX_RETRIES,
|
||||
e,
|
||||
retry_delay,
|
||||
)
|
||||
await anyio.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
|
||||
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
|
||||
raise RuntimeError("retry loop exited without capturing an error")
|
||||
@@ -76,3 +86,7 @@ def retry_on_rate_limit(
|
||||
return wrapper
|
||||
|
||||
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.errors import SDKError
|
||||
|
||||
from ._retry import retry_on_rate_limit
|
||||
from ._retry import retry_on_transient
|
||||
from .base import Provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -30,13 +30,17 @@ BATCH_SIZE = 64
|
||||
_NO_EMBEDDING_MODEL_MSG = "Embedding not supported - no embedding_model configured"
|
||||
|
||||
|
||||
def _is_rate_limit(exc: BaseException) -> bool:
|
||||
"""True only for HTTP 429 SDKErrors."""
|
||||
return getattr(exc, "status_code", None) == 429
|
||||
def _is_transient(exc: BaseException) -> bool:
|
||||
"""Retry HTTP 429 (rate limit) and 5xx (server/transient) SDKErrors."""
|
||||
status = getattr(exc, "status_code", None)
|
||||
return status == 429 or (isinstance(status, int) and status >= 500)
|
||||
|
||||
|
||||
_retry_429 = retry_on_rate_limit(
|
||||
SDKError, is_rate_limit=_is_rate_limit, provider_name="Mistral"
|
||||
_retry_transient = retry_on_transient(
|
||||
SDKError,
|
||||
should_retry=_is_transient,
|
||||
provider_name="Mistral",
|
||||
label="transient error",
|
||||
)
|
||||
|
||||
|
||||
@@ -90,7 +94,7 @@ class MistralProvider(Provider):
|
||||
def supports_generation(self) -> bool:
|
||||
return False
|
||||
|
||||
@_retry_429
|
||||
@_retry_transient
|
||||
async def embed(self, text: str) -> list[float]:
|
||||
"""Generate an embedding for a single text."""
|
||||
if not self.supports_embeddings:
|
||||
@@ -169,7 +173,7 @@ class MistralProvider(Provider):
|
||||
|
||||
return all_embeddings, total_tokens
|
||||
|
||||
@_retry_429
|
||||
@_retry_transient
|
||||
async def _embed_batch_request(
|
||||
self, batch: list[str]
|
||||
) -> tuple[list[list[float]], int]:
|
||||
|
||||
@@ -8,16 +8,37 @@ Supports:
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
_retry_429 = retry_on_rate_limit(RateLimitError, provider_name="OpenAI")
|
||||
|
||||
def _is_transient(exc: BaseException) -> bool:
|
||||
"""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
|
||||
@@ -95,7 +116,7 @@ class OpenAIProvider(Provider):
|
||||
"""Whether this provider supports text generation."""
|
||||
return self.generation_model is not None
|
||||
|
||||
@_retry_429
|
||||
@_retry_transient
|
||||
async def embed(self, text: str) -> list[float]:
|
||||
"""
|
||||
Generate embedding vector for text.
|
||||
@@ -208,7 +229,7 @@ class OpenAIProvider(Provider):
|
||||
|
||||
return all_embeddings, total_tokens
|
||||
|
||||
@_retry_429
|
||||
@_retry_transient
|
||||
async def _embed_batch_request(
|
||||
self, batch: list[str]
|
||||
) -> tuple[list[list[float]], int]:
|
||||
@@ -262,7 +283,7 @@ class OpenAIProvider(Provider):
|
||||
)
|
||||
return self._dimension
|
||||
|
||||
@_retry_429
|
||||
@_retry_transient
|
||||
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
|
||||
"""
|
||||
Generate text from a prompt.
|
||||
|
||||
@@ -9,6 +9,7 @@ import uuid
|
||||
from typing import Any, cast
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
from anyio.abc import TaskStatus
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream
|
||||
from qdrant_client.models import PointStruct
|
||||
@@ -23,6 +24,7 @@ from nextcloud_mcp_server.observability.metrics import (
|
||||
record_document_parse_failed,
|
||||
record_embedding,
|
||||
record_embedding_tokens,
|
||||
record_ingest_dropped,
|
||||
record_qdrant_operation,
|
||||
record_vector_sync_processing,
|
||||
update_vector_sync_queue_size,
|
||||
@@ -56,6 +58,47 @@ logger = logging.getLogger(__name__)
|
||||
_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):
|
||||
"""Assign page numbers to chunks based on page boundaries.
|
||||
|
||||
@@ -455,11 +498,13 @@ async def process_document(
|
||||
await anyio.sleep(retry_delay)
|
||||
retry_delay *= 2 # Exponential backoff
|
||||
else:
|
||||
reason = _drop_reason(e)
|
||||
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_id,
|
||||
max_retries,
|
||||
reason,
|
||||
e,
|
||||
extra={
|
||||
"doc_id": doc_task.doc_id,
|
||||
@@ -467,12 +512,18 @@ async def process_document(
|
||||
"attempt": max_retries,
|
||||
"max_retries": max_retries,
|
||||
"status": "error",
|
||||
"drop_reason": reason,
|
||||
},
|
||||
)
|
||||
# Record the failed Qdrant upsert. The processing-error
|
||||
# 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_ingest_dropped(reason)
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
|
||||
Reference in New Issue
Block a user