diff --git a/litellm/llms/mongodb/common_utils.py b/litellm/llms/mongodb/common_utils.py index 2571821381a..299a9772817 100644 --- a/litellm/llms/mongodb/common_utils.py +++ b/litellm/llms/mongodb/common_utils.py @@ -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 diff --git a/litellm/llms/mongodb/vector_stores/transformation.py b/litellm/llms/mongodb/vector_stores/transformation.py index 20dcf62dcc3..2570e368990 100644 --- a/litellm/llms/mongodb/vector_stores/transformation.py +++ b/litellm/llms/mongodb/vector_stores/transformation.py @@ -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 ''}.{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://:@.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." ) diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index c9378015f5a..1e5bce3f0ee 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -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 + )