fix(vector_stores): return 400 for MongoDB misconfiguration instead of 500

litellm.exception_type passes a litellm exception through untouched and wraps
anything else into APIConnectionError, so every bare ValueError this provider
raised reached the caller as HTTP 500 with a Python traceback in the response
body. "max_num_results must be between 1 and 50" is the caller's to fix, not a
connection failure.

Configuration and validation failures now raise BadRequestError (400) and the
two timeout cases raise Timeout (408). ExecutionTimeout subclasses
OperationFailure, so it is matched before it; previously an Atlas query that ran
out of time was reported as "MongoDB rejected the vector search".
This commit is contained in:
Yuneng Jiang 2026-09-02 10:01:31 -07:00
parent 22d34960e5
commit 8374b34b81
No known key found for this signature in database
3 changed files with 147 additions and 55 deletions

View file

@ -12,6 +12,8 @@ import asyncio
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final
from litellm.exceptions import BadRequestError, Timeout
if TYPE_CHECKING:
from pymongo import AsyncMongoClient, MongoClient
@ -20,6 +22,19 @@ PYMONGO_INSTALL_HINT: Final = (
"Run 'pip install litellm[mongodb]' (or 'pip install pymongo') to install it."
)
MONGODB_PROVIDER: Final = "mongodb"
def config_error(message: str) -> BadRequestError:
"""Misconfiguration is the caller's to fix, so it maps to 400 rather than the 500
a bare ValueError would become once litellm.exception_type wraps it."""
return BadRequestError(message=message, model=None, llm_provider=MONGODB_PROVIDER)
def timeout_error(message: str) -> Timeout:
return Timeout(message=message, model=None, llm_provider=MONGODB_PROVIDER)
DEFAULT_CONNECT_TIMEOUT_MS: Final = 10_000
DEFAULT_SOCKET_TIMEOUT_MS: Final = 30_000
DEFAULT_SERVER_SELECTION_TIMEOUT_MS: Final = 10_000
@ -45,7 +60,7 @@ def import_sync_mongo_client() -> "type[MongoClient]":
try:
from pymongo import MongoClient as SyncMongoClient
except ImportError as e:
raise ValueError(PYMONGO_INSTALL_HINT) from e
raise config_error(PYMONGO_INSTALL_HINT) from e
return SyncMongoClient
@ -53,7 +68,7 @@ def import_async_mongo_client() -> "type[AsyncMongoClient]":
try:
from pymongo import AsyncMongoClient as AsyncMongoClientClass
except ImportError as e:
raise ValueError(PYMONGO_INSTALL_HINT) from e
raise config_error(PYMONGO_INSTALL_HINT) from e
return AsyncMongoClientClass
@ -105,19 +120,19 @@ def _index_hint(index_name: str, database: str, collection: str) -> str:
)
def missing_index_error(index_name: str, database: str, collection: str) -> ValueError:
def missing_index_error(index_name: str, database: str, collection: str) -> BadRequestError:
"""$vectorSearch against a missing index, database or collection returns zero documents
instead of failing, so an empty result set is checked against the index catalogue and
turned into this rather than being reported as 'no matches'."""
return ValueError(
return config_error(
f"{_index_hint(index_name, database, collection)} A vector search against a database, "
"collection or index that does not exist returns no results rather than an error, so this "
"was reported as an empty result set by MongoDB."
)
def index_not_ready_error(index_name: str, database: str, collection: str, status: str) -> ValueError:
return ValueError(
def index_not_ready_error(index_name: str, database: str, collection: str, status: str) -> BadRequestError:
return config_error(
f"The Atlas Vector Search index '{index_name}' on '{database}.{collection}' is not queryable "
f"yet; its status is {status}. Searches against it return no results until the build finishes."
)
@ -141,46 +156,47 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll
return error
if isinstance(error, ServerSelectionTimeoutError):
return ValueError(
return timeout_error(
"Could not reach the MongoDB deployment before the timeout. On Atlas this is usually the "
"project's IP access list not containing this host, or a paused cluster; it can also be an "
f"unresolvable hostname. Driver detail: {error}"
)
# ExecutionTimeout subclasses OperationFailure, so it has to be matched before it
if isinstance(error, (NetworkTimeout, ExecutionTimeout)):
return timeout_error(
f"The MongoDB vector search against '{database}.{collection}' timed out before returning. "
f"Driver detail: {error}"
)
if isinstance(error, OperationFailure):
code: Final = error.code
if code in (_AUTHENTICATION_FAILED_CODE, _UNAUTHORIZED_CODE):
return ValueError(
return config_error(
"MongoDB rejected the credentials in mongodb_connection_string, or the database user "
f"lacks read access to '{database}.{collection}'. Driver detail: {error.details}"
)
detail: Final = str(error).lower()
if "dimension" in detail:
return ValueError(
return config_error(
"The query embedding does not match the vector dimensions the Atlas index was built for. "
"litellm_embedding_model must be the same model that produced the stored vectors. "
f"Driver detail: {error}"
)
if "is not indexed as vector" in detail:
return ValueError(
return config_error(
"mongodb_embedding_field names a field the Atlas Vector Search index does not cover. "
f"It must match the 'path' the index '{index_name}' was created on. Driver detail: {error}"
)
if "index" in detail and ("not found" in detail or "does not exist" in detail or "unknown" in detail):
return ValueError(f"{_index_hint(index_name, database, collection)} Driver detail: {error}")
return ValueError(
return config_error(f"{_index_hint(index_name, database, collection)} Driver detail: {error}")
return config_error(
f"MongoDB rejected the vector search against '{database}.{collection}' using index "
f"'{index_name}'. Driver detail: {error}"
)
if isinstance(error, (NetworkTimeout, ExecutionTimeout)):
return ValueError(
f"The MongoDB vector search against '{database}.{collection}' timed out before returning. "
f"Driver detail: {error}"
)
if isinstance(error, ConfigurationError):
return ValueError(
return config_error(
"mongodb_connection_string is not a usable MongoDB connection string. "
f"Driver detail: {error}"
)
if isinstance(error, InvalidOperation):
return ValueError(f"The MongoDB client was already closed or is unusable. Driver detail: {error}")
return config_error(f"The MongoDB client was already closed or is unusable. Driver detail: {error}")
return error

View file

@ -24,6 +24,7 @@ from litellm.llms.mongodb.common_utils import (
DEFAULT_SERVER_SELECTION_TIMEOUT_MS,
DEFAULT_SOCKET_TIMEOUT_MS,
MongoClientKey,
config_error,
get_async_client,
get_sync_client,
index_not_ready_error,
@ -88,7 +89,7 @@ class _MongoDBSearchParams(BaseModel):
def require_embedding_model(self) -> str:
if not self.litellm_embedding_model:
raise ValueError(
raise config_error(
"litellm_embedding_model is required in litellm_params for the MongoDB vector store. "
"It must be the same model that produced the vectors stored in "
f"'{self.mongodb_collection or '<collection>'}.{self.embedding_field}', or search results "
@ -98,13 +99,13 @@ class _MongoDBSearchParams(BaseModel):
def require_connection_string(self) -> str:
if not self.mongodb_connection_string:
raise ValueError(
raise config_error(
"mongodb_connection_string is required in litellm_params for the MongoDB vector store. "
"Example: mongodb+srv://<user>:<password>@<cluster>.mongodb.net"
)
scheme: Final = self.mongodb_connection_string.split("://", 1)[0].lower()
if scheme not in ("mongodb", "mongodb+srv"):
raise ValueError(
raise config_error(
"mongodb_connection_string must start with 'mongodb://' or 'mongodb+srv://', "
f"got '{self.mongodb_connection_string.split('://', 1)[0]}://'"
)
@ -112,7 +113,7 @@ class _MongoDBSearchParams(BaseModel):
def require_database(self) -> str:
if not self.mongodb_database:
raise ValueError(
raise config_error(
"mongodb_database is required in litellm_params for the MongoDB vector store. "
"Example: mongodb_database: sample_mflix"
)
@ -120,7 +121,7 @@ class _MongoDBSearchParams(BaseModel):
def require_collection(self) -> str:
if not self.mongodb_collection:
raise ValueError(
raise config_error(
"mongodb_collection is required in litellm_params for the MongoDB vector store. "
"Example: mongodb_collection: embedded_movies"
)
@ -145,9 +146,9 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig):
def _query_text(query: str | Sequence[str]) -> str:
text: Final = query if isinstance(query, str) else " ".join(query)
if not text.strip():
raise ValueError("query must not be empty")
raise config_error("query must not be empty")
if len(text) > MAX_QUERY_CHARACTERS:
raise ValueError(f"query must be at most {MAX_QUERY_CHARACTERS} characters, got {len(text)}")
raise config_error(f"query must be at most {MAX_QUERY_CHARACTERS} characters, got {len(text)}")
return text
@staticmethod
@ -156,7 +157,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig):
if requested is None:
return DEFAULT_MAX_NUM_RESULTS
if not MIN_MAX_NUM_RESULTS <= requested <= MAX_MAX_NUM_RESULTS:
raise ValueError(
raise config_error(
f"max_num_results must be between {MIN_MAX_NUM_RESULTS} and {MAX_MAX_NUM_RESULTS}, got {requested}"
)
return requested
@ -165,7 +166,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig):
def _num_candidates(limit: int, configured: int | None) -> int:
if configured is not None:
if not limit <= configured <= MAX_NUM_CANDIDATES:
raise ValueError(
raise config_error(
f"mongodb_num_candidates must be between max_num_results ({limit}) and "
f"{MAX_NUM_CANDIDATES}, got {configured}"
)
@ -199,7 +200,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig):
vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams,
) -> list[dict[str, object]]:
if vector_store_search_optional_params.get("filters") is not None:
raise ValueError(
raise config_error(
"MongoDB vector store does not support the filters parameter yet. "
"Restrict the collection or the Atlas Vector Search index definition instead."
)
@ -268,7 +269,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig):
def _embedding_vector(embedding_response: EmbeddingResponse) -> Sequence[float]:
data: Final = embedding_response.data
if not data:
raise ValueError(
raise config_error(
"The embedding model returned no embedding for the search query, so there is nothing "
"to search MongoDB with. Check the embedding deployment named by litellm_embedding_model."
)

View file

@ -5,8 +5,11 @@ from unittest.mock import MagicMock, patch
import httpx
import pytest
from litellm.exceptions import BadRequestError, Timeout
from litellm.llms.mongodb.common_utils import (
MongoClientKey,
index_not_ready_error,
missing_index_error,
get_async_client,
get_sync_client,
reset_client_cache,
@ -219,7 +222,7 @@ def test_num_candidates_can_be_overridden():
def test_num_candidates_below_the_limit_or_above_the_ceiling_is_rejected(configured):
config, _, _ = _config()
with pytest.raises(ValueError, match="mongodb_num_candidates"):
with pytest.raises(BadRequestError, match="mongodb_num_candidates"):
_search(config, optional_params={"max_num_results": 5}, litellm_params={"mongodb_num_candidates": configured})
@ -296,7 +299,7 @@ def test_response_stringifies_a_non_string_document_id():
def test_search_requires_an_embedding_model():
config, _, _ = _config()
with pytest.raises(ValueError, match="litellm_embedding_model is required"):
with pytest.raises(BadRequestError, match="litellm_embedding_model is required"):
config.execute_search_vector_store_request(
vector_store_id=INDEX,
query="q",
@ -309,7 +312,7 @@ def test_search_requires_an_embedding_model():
def test_missing_embedding_model_message_names_the_field_being_searched():
config, _, _ = _config()
with pytest.raises(ValueError, match=r"embedded_movies\.embedding"):
with pytest.raises(BadRequestError, match=r"embedded_movies\.embedding"):
config.execute_search_vector_store_request(
vector_store_id=INDEX,
query="q",
@ -322,7 +325,7 @@ def test_missing_embedding_model_message_names_the_field_being_searched():
def test_search_requires_a_connection_string():
config, _, _ = _config()
with pytest.raises(ValueError, match="mongodb_connection_string is required"):
with pytest.raises(BadRequestError, match="mongodb_connection_string is required"):
_search(config, litellm_params={"mongodb_connection_string": None})
@ -330,7 +333,7 @@ def test_search_requires_a_connection_string():
def test_search_rejects_a_non_mongodb_connection_scheme(connection_string):
config, _, _ = _config()
with pytest.raises(ValueError, match="must start with 'mongodb://' or 'mongodb\\+srv://'"):
with pytest.raises(BadRequestError, match="must start with 'mongodb://' or 'mongodb\\+srv://'"):
_search(config, litellm_params={"mongodb_connection_string": connection_string})
@ -345,21 +348,21 @@ def test_search_accepts_the_plain_mongodb_scheme():
def test_search_requires_a_database():
config, _, _ = _config()
with pytest.raises(ValueError, match="mongodb_database is required"):
with pytest.raises(BadRequestError, match="mongodb_database is required"):
_search(config, litellm_params={"mongodb_database": None})
def test_search_requires_a_collection():
config, _, _ = _config()
with pytest.raises(ValueError, match="mongodb_collection is required"):
with pytest.raises(BadRequestError, match="mongodb_collection is required"):
_search(config, litellm_params={"mongodb_collection": None})
def test_search_rejects_filters_rather_than_silently_ignoring_them():
config, _, _ = _config()
with pytest.raises(ValueError, match="does not support the filters parameter"):
with pytest.raises(BadRequestError, match="does not support the filters parameter"):
_search(config, optional_params={"filters": {"genre": "sci-fi"}})
@ -367,7 +370,7 @@ def test_search_rejects_filters_rather_than_silently_ignoring_them():
async def test_async_search_rejects_filters_rather_than_silently_ignoring_them():
config, _, _ = _async_config()
with pytest.raises(ValueError, match="does not support the filters parameter"):
with pytest.raises(BadRequestError, match="does not support the filters parameter"):
await _asearch(config, optional_params={"filters": {"genre": "sci-fi"}})
@ -375,14 +378,14 @@ async def test_async_search_rejects_filters_rather_than_silently_ignoring_them()
def test_search_rejects_an_empty_query(query):
config, _, _ = _config()
with pytest.raises(ValueError, match="query must not be empty"):
with pytest.raises(BadRequestError, match="query must not be empty"):
_search(config, query=query)
def test_search_rejects_an_oversized_query():
config, _, _ = _config()
with pytest.raises(ValueError, match="at most 32000 characters"):
with pytest.raises(BadRequestError, match="at most 32000 characters"):
_search(config, query="x" * 32_001)
@ -398,7 +401,7 @@ def test_search_accepts_a_query_at_the_size_ceiling():
def test_search_rejects_out_of_range_max_num_results(max_num_results):
config, _, _ = _config()
with pytest.raises(ValueError, match="max_num_results must be between 1 and 50"):
with pytest.raises(BadRequestError, match="max_num_results must be between 1 and 50"):
_search(config, optional_params={"max_num_results": max_num_results})
@ -422,7 +425,7 @@ def test_search_treats_an_explicit_null_max_num_results_as_the_default():
def test_search_fails_when_the_embedding_model_returns_nothing():
config, _, _ = _config(embedding=None)
with pytest.raises(ValueError, match="returned no embedding"):
with pytest.raises(BadRequestError, match="returned no embedding"):
_search(config)
@ -433,7 +436,7 @@ def test_validation_runs_before_any_connection_is_opened():
sync_client_factory=lambda key: opened.append(key) or FakeClient(FakeCollection([])),
)
with pytest.raises(ValueError, match="query must not be empty"):
with pytest.raises(BadRequestError, match="query must not be empty"):
_search(config, query="")
assert opened == []
@ -474,7 +477,7 @@ async def test_async_search_builds_the_same_pipeline_and_maps_the_response():
async def test_async_search_requires_an_embedding_model():
config, _, _ = _async_config()
with pytest.raises(ValueError, match="litellm_embedding_model is required"):
with pytest.raises(BadRequestError, match="litellm_embedding_model is required"):
await config.aexecute_search_vector_store_request(
vector_store_id=INDEX,
query="q",
@ -627,7 +630,7 @@ class TestErrorTranslation:
config, _, _ = _config(error=ServerSelectionTimeoutError("no servers"))
with pytest.raises(ValueError, match="IP access list"):
with pytest.raises(Timeout, match="IP access list"):
_search(config)
@pytest.mark.asyncio
@ -636,7 +639,7 @@ class TestErrorTranslation:
config, _, _ = _async_config(error=OperationFailure("auth failed", code=18))
with pytest.raises(ValueError, match="rejected the credentials"):
with pytest.raises(BadRequestError, match="rejected the credentials"):
await _asearch(config)
@ -645,14 +648,14 @@ class TestMissingDriver:
from litellm.llms.mongodb.common_utils import import_sync_mongo_client
with patch.dict(sys.modules, {"pymongo": None}):
with pytest.raises(ValueError, match=r"pip install litellm\[mongodb\]"):
with pytest.raises(BadRequestError, match=r"pip install litellm\[mongodb\]"):
import_sync_mongo_client()
def test_the_async_import_names_the_extra_to_install(self):
from litellm.llms.mongodb.common_utils import import_async_mongo_client
with patch.dict(sys.modules, {"pymongo": None}):
with pytest.raises(ValueError, match=r"pip install litellm\[mongodb\]"):
with pytest.raises(BadRequestError, match=r"pip install litellm\[mongodb\]"):
import_async_mongo_client()
def test_error_translation_degrades_gracefully_without_the_driver(self):
@ -670,7 +673,7 @@ class TestEmptyResultsAreDisambiguated:
def test_a_missing_index_becomes_an_error_rather_than_an_empty_page(self):
config, _, collection = _config(documents=[], search_indexes=[])
with pytest.raises(ValueError, match="No queryable Atlas Vector Search index"):
with pytest.raises(BadRequestError, match="No queryable Atlas Vector Search index"):
_search(config)
assert collection.listed_indexes == [INDEX]
@ -678,7 +681,7 @@ class TestEmptyResultsAreDisambiguated:
def test_the_missing_index_error_explains_why_mongodb_reported_no_results(self):
config, _, _ = _config(documents=[], search_indexes=[])
with pytest.raises(ValueError, match="returns no results rather than an error"):
with pytest.raises(BadRequestError, match="returns no results rather than an error"):
_search(config)
def test_an_index_still_building_becomes_an_error_naming_its_status(self):
@ -686,7 +689,7 @@ class TestEmptyResultsAreDisambiguated:
documents=[], search_indexes=[{"name": INDEX, "status": "PENDING", "queryable": False}]
)
with pytest.raises(ValueError, match="not queryable yet; its status is PENDING"):
with pytest.raises(BadRequestError, match="not queryable yet; its status is PENDING"):
_search(config)
def test_a_genuine_no_match_against_a_ready_index_returns_an_empty_page(self):
@ -709,7 +712,7 @@ class TestEmptyResultsAreDisambiguated:
async def test_async_missing_index_becomes_an_error_rather_than_an_empty_page(self):
config, _, collection = _async_config(documents=[], search_indexes=[])
with pytest.raises(ValueError, match="No queryable Atlas Vector Search index"):
with pytest.raises(BadRequestError, match="No queryable Atlas Vector Search index"):
await _asearch(config)
assert collection.listed_indexes == [INDEX]
@ -720,7 +723,7 @@ class TestEmptyResultsAreDisambiguated:
documents=[], search_indexes=[{"name": INDEX, "status": "PENDING", "queryable": False}]
)
with pytest.raises(ValueError, match="not queryable yet; its status is PENDING"):
with pytest.raises(BadRequestError, match="not queryable yet; its status is PENDING"):
await _asearch(config)
@pytest.mark.asyncio
@ -752,7 +755,7 @@ class TestEmptyResultsAreDisambiguated:
sync_client_factory=lambda key: FakeClient(collection),
)
with pytest.raises(ValueError, match="lacks read access"):
with pytest.raises(BadRequestError, match="lacks read access"):
_search(config)
@ -785,3 +788,75 @@ class TestAtlasPlanExecutorErrors:
assert "does not match the vector dimensions" in str(translated)
assert "mongodb_embedding_field" not in str(translated)
class TestErrorsCarryTheRightHttpStatus:
"""litellm.exception_type passes a litellm exception through untouched but wraps anything
else into APIConnectionError, which the proxy serves as a 500 with a Python traceback in the
body. A misconfigured connection string is the caller's to fix, so it has to arrive as a 400.
"""
@pytest.mark.parametrize(
"invoke",
[
pytest.param(lambda: _search(_config()[0], query=" "), id="empty-query"),
pytest.param(
lambda: _search(_config()[0], optional_params={"max_num_results": 999}),
id="max-num-results-out-of-range",
),
pytest.param(
lambda: _search(_config()[0], optional_params={"filters": {"genre": "Action"}}),
id="unsupported-filters",
),
pytest.param(
lambda: _search(_config()[0], litellm_params={"mongodb_connection_string": "postgres://host/db"}),
id="wrong-uri-scheme",
),
pytest.param(
lambda: _search(_config()[0], litellm_params={"mongodb_database": None}), id="missing-database"
),
pytest.param(
lambda: _search(_config()[0], litellm_params={"litellm_embedding_model": None}),
id="missing-embedding-model",
),
],
)
def test_configuration_failures_are_400(self, invoke):
with pytest.raises(BadRequestError) as excinfo:
invoke()
assert excinfo.value.status_code == 400
assert excinfo.value.llm_provider == "mongodb"
def test_missing_index_is_400(self):
error = missing_index_error("idx", "db", "coll")
assert error.status_code == 400
assert error.llm_provider == "mongodb"
def test_index_still_building_is_400(self):
error = index_not_ready_error("idx", "db", "coll", "PENDING")
assert error.status_code == 400
def test_unreachable_deployment_is_a_timeout_not_a_bad_request(self):
from pymongo.errors import ServerSelectionTimeoutError
translated = translate_mongo_error(
ServerSelectionTimeoutError("no servers"), index_name="idx", database="db", collection="coll"
)
assert isinstance(translated, Timeout)
assert translated.status_code == 408
def test_query_execution_timeout_is_a_timeout(self):
from pymongo.errors import ExecutionTimeout
translated = translate_mongo_error(
ExecutionTimeout("too slow"), index_name="idx", database="db", collection="coll"
)
assert isinstance(translated, Timeout)
assert translated.status_code == 408
def test_unrecognised_errors_are_not_relabelled_as_bad_requests(self):
original = RuntimeError("something else entirely")
assert (
translate_mongo_error(original, index_name="idx", database="db", collection="coll")
is original
)