fix(vector_stores): make MongoDB errors actionable on self-managed deployments

mongod serves $vectorSearch identically whether mongot runs under Atlas or beside
a self-managed deployment, so the provider already worked against on-prem. The
guidance did not: a refused connection told the operator to check their project's
IP access list and whether the cluster was paused, neither of which exists outside
Atlas, and the index errors claimed an "Atlas Vector Search index" they do not have.
Every message now names a remedy for both, keeping the Atlas-specific hint labelled
as such.

Also diagnoses unescaped credentials, which self-managed deployments hit more often
because the password is usually generated. pymongo reports those three different
ways and none of them mentions the password: '@', ':' and '%' raise an RFC 3986
complaint, '/' is read as the database separator and surfaces as Bad database name,
and an unescaped ':' looks like a bad port and comes back as a plain ValueError.
All three now point at the credentials. The ValueError branch's comment claimed it
fired on an unescaped '/', which pymongo actually reports as InvalidURI; corrected
to the port parse it really catches.

Verified against a self-managed mongod 8.0 with mongot, reached over plain
mongodb:// with no SRV and no TLS: 13 cases with live OpenAI embeddings, and 4
credential cases against an auth-enabled instance whose password holds % @ / and :.
list_search_indexes returns the same queryable and status fields there as on Atlas,
so the index-readiness check needed no change.
This commit is contained in:
Yuneng Jiang 2026-09-04 13:39:54 -07:00
parent 63482cfdbd
commit 50fb35e17e
No known key found for this signature in database
3 changed files with 205 additions and 31 deletions

View file

@ -1,10 +1,10 @@
"""Shared helpers for MongoDB Atlas integrations.
"""Shared helpers for MongoDB integrations.
pymongo ships in the optional ``mongodb`` extra, so every import of it is
deferred to call time and raises an actionable error when it is absent.
Clients are cached per connection because building one costs an SRV lookup, a
TLS handshake and topology discovery: measured at ~890ms against Atlas versus
TLS handshake and topology discovery: measured at ~890ms against a remote deployment versus
~80ms on a warm client, so a client per search would dominate query latency.
"""
@ -141,15 +141,16 @@ def reset_client_cache() -> None:
_AUTHENTICATION_FAILED_CODE: Final = 18
_UNAUTHORIZED_CODE: Final = 13
# Atlas reports a rejected user as code 8000 "AtlasError", not 18, so only the message is reliable
# Atlas reports a rejected user as code 8000 "AtlasError" where a self-managed mongod reports 18
_AUTHENTICATION_MESSAGE_MARKERS: Final = ("bad auth", "authentication failed", "not authorized")
_RESOLUTION_TIMEOUT_MARKERS: Final = ("resolution lifetime expired", "dns operation timed out")
_UNKNOWN_HOSTNAME_MARKERS: Final = ("dns query name does not exist", "name or service not known")
_CREDENTIAL_ESCAPING_MARKERS: Final = ("must be escaped according to rfc 3986", "bad database name")
def _index_hint(index_name: str, database: str, collection: str) -> str:
return (
f"No queryable Atlas Vector Search index named '{index_name}' was found on "
f"No queryable MongoDB Vector Search index named '{index_name}' was found on "
f"'{database}.{collection}'. Confirm the index exists on that exact collection, that its "
"status is READY rather than still building, and that the vector store id matches the index name."
)
@ -168,7 +169,7 @@ def missing_index_error(index_name: str, database: str, collection: str) -> BadR
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"The MongoDB 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."
)
@ -194,8 +195,9 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll
if isinstance(error, ServerSelectionTimeoutError):
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}"
"project's IP access list not containing this host, or a paused cluster. On a self-managed "
"deployment it is usually the host or port in the URI, or a firewall between this process "
f"and mongod. Either way it can also be an unresolvable hostname. Driver detail: {error}"
)
# ExecutionTimeout subclasses OperationFailure, so it has to be matched before it
if isinstance(error, (NetworkTimeout, ExecutionTimeout)):
@ -208,8 +210,9 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll
if isinstance(error, ConnectionFailure):
return config_error(
f"The connection to '{database}.{collection}' was refused or dropped. On Atlas this is "
"usually a connection string with no username and password, or a TLS failure. Confirm "
f"the URI is the one Atlas shows under Connect, Drivers. Driver detail: {error}"
"usually a connection string with no username and password, or a TLS failure, so confirm "
"the URI is the one Atlas shows under Connect, Drivers. On a self-managed deployment, check "
f"that mongod is listening on the host and port in the URI. Driver detail: {error}"
)
if isinstance(error, OperationFailure):
code: Final = error.code
@ -223,13 +226,13 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll
)
if "dimension" in detail:
return config_error(
"The query embedding does not match the vector dimensions the Atlas index was built for. "
"The query embedding does not match the vector dimensions the 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 config_error(
"mongodb_embedding_field names a field the Atlas Vector Search index does not cover. "
"mongodb_embedding_field names a field the MongoDB 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):
@ -248,19 +251,28 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll
)
if any(marker in configuration_detail for marker in _UNKNOWN_HOSTNAME_MARKERS):
return config_error(
"The cluster hostname in mongodb_connection_string does not exist in DNS. Check the "
f"cluster name against the URI Atlas shows under Connect, Drivers. Driver detail: {error}"
"The hostname in mongodb_connection_string does not exist in DNS. On Atlas, check the "
"cluster name against the URI shown under Connect, Drivers. On a self-managed deployment, "
f"check that the hostname resolves from this process. Driver detail: {error}"
)
if any(marker in configuration_detail for marker in _CREDENTIAL_ESCAPING_MARKERS):
return config_error(
"mongodb_connection_string could not be parsed. A username or password containing "
"'@', '/', ':' or '%' has to be percent-encoded per RFC 3986, so 'p@ss/word' becomes "
"'p%40ss%2Fword'. If the credentials are already encoded, check the database name in "
f"the URI path instead. Driver detail: {error}"
)
return config_error(
f"mongodb_connection_string is not a usable MongoDB connection string. Driver detail: {error}"
)
if isinstance(error, InvalidOperation):
return config_error(f"The MongoDB client was already closed or is unusable. Driver detail: {error}")
# pymongo's URI parser raises a plain ValueError, not a PyMongoError, for a password holding an
# unescaped '/', which would otherwise reach the caller as a 500
# pymongo raises a plain ValueError, not a PyMongoError, for an unusable port, which an unescaped
# ':' in a password also produces, and which would otherwise reach the caller as a 500
if isinstance(error, ValueError):
return config_error(
"mongodb_connection_string could not be parsed. A username or password containing "
f"'@', '/', ':' or '%' has to be percent-encoded per RFC 3986. Driver detail: {error}"
"The host and port in mongodb_connection_string could not be parsed. If the port is a "
"number between 0 and 65535, the cause is usually an unescaped ':' in the password, which "
f"has to be percent-encoded per RFC 3986 as '%3A'. Driver detail: {error}"
)
return error

View file

@ -1,11 +1,12 @@
"""MongoDB Atlas vector store provider.
"""MongoDB vector store provider, for Atlas and self-managed deployments alike.
Atlas Vector Search has no HTTP query API (the Data API and HTTPS Endpoints are
MongoDB Vector Search has no HTTP query API (the Data API and HTTPS Endpoints are
end-of-life), so this config extends BaseDirectVectorStoreConfig and runs the
``$vectorSearch`` aggregation itself through pymongo instead of shaping an httpx
request.
request. mongod serves that stage identically whether mongot runs under Atlas or
beside a self-managed deployment, so one code path covers both.
``vector_store_id`` is the Atlas Search index name, matching the Valkey provider
``vector_store_id`` is the search index name, matching the Valkey provider
where the id names the index; the database and collection it covers come from
litellm_params.
"""
@ -60,7 +61,7 @@ MAX_QUERY_CHARACTERS: Final = 32_000
_EMPTY_EMBEDDING_CONFIG: Final = MappingProxyType({})
_SEARCH_ONLY_MESSAGE: Final = (
"MongoDB vector store is search-only. Create the collection and its Atlas Vector Search "
"MongoDB vector store is search-only. Create the collection and its MongoDB Vector Search "
"index in MongoDB directly, then register it here by index name."
)
@ -101,7 +102,8 @@ class _MongoDBSearchParams(BaseModel):
if not self.mongodb_connection_string:
raise config_error(
"mongodb_connection_string is required in litellm_params for the MongoDB vector store. "
"Example: mongodb+srv://<user>:<password>@<cluster>.mongodb.net"
"Example: mongodb+srv://<user>:<password>@<cluster>.mongodb.net for Atlas, or "
"mongodb://<user>:<password>@<host>:27017 for a self-managed deployment"
)
scheme: Final = self.mongodb_connection_string.split("://", 1)[0].lower()
if scheme not in ("mongodb", "mongodb+srv"):
@ -234,12 +236,12 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig):
if vector_store_search_optional_params.get("filters") is not None:
raise config_error(
"MongoDB vector store does not support the filters parameter yet. "
"Restrict the collection or the Atlas Vector Search index definition instead."
"Restrict the collection or the MongoDB Vector Search index definition instead."
)
if vector_store_search_optional_params.get("ranking_options") is not None:
raise config_error(
"MongoDB vector store does not support the ranking_options parameter yet. "
"Every result already carries the Atlas vectorSearchScore, so filter or re-rank "
"Every result already carries the vectorSearchScore, so filter or re-rank "
"on that rather than having the threshold silently ignored."
)
if vector_store_search_optional_params.get("rewrite_query") is not None:
@ -296,7 +298,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig):
def _raise_for_missing_text_field(
cls, documents: Sequence[Mapping[str, object]], text_field: str, database: str, collection: str
) -> None:
"""Atlas happily matches vectors in documents that carry no text at all, so a mistyped
"""$vectorSearch happily matches documents that carry no text at all, so a mistyped
mongodb_text_field returns well-scored results whose content is empty and feeds an empty
context to the model. Every matched document lacking the field is the misconfiguration."""
if documents and all(cls._field_value(document, text_field) is None for document in documents):
@ -322,7 +324,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig):
def _raise_for_unusable_index(
catalogue: Sequence[Mapping[str, object]], index_name: str, database: str, collection: str
) -> None:
"""An empty result set is ambiguous: Atlas returns zero documents both for a query that
"""An empty result set is ambiguous: mongod returns zero documents both for a query that
genuinely matched nothing and for a missing database, collection or index. Only the second
is a misconfiguration, so the index catalogue decides which one happened."""
if not catalogue:

View file

@ -788,8 +788,8 @@ class TestErrorTranslation:
assert "refused or dropped" not in str(translated)
def test_an_unescaped_password_character_is_a_400_not_a_500(self):
"""pymongo's URI parser raises a plain ValueError, not a PyMongoError, when a password
holds an unescaped '/'. That is a routine mistake and it must not be a 500."""
"""pymongo's URI parser raises a plain ValueError, not a PyMongoError, for an unusable port,
which is also what an unescaped ':' in a password produces. It must not be a 500."""
translated = self._translate(ValueError("Port contains non-digit characters"))
assert isinstance(translated, BadRequestError)
@ -895,7 +895,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(BadRequestError, match="No queryable Atlas Vector Search index"):
with pytest.raises(BadRequestError, match="No queryable MongoDB Vector Search index"):
_search(config)
assert collection.listed_indexes == [INDEX]
@ -934,7 +934,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(BadRequestError, match="No queryable Atlas Vector Search index"):
with pytest.raises(BadRequestError, match="No queryable MongoDB Vector Search index"):
await _asearch(config)
assert collection.listed_indexes == [INDEX]
@ -1202,3 +1202,163 @@ class TestClientConstructionFailures:
with pytest.raises(BadRequestError, match="not a usable MongoDB connection string"):
await _asearch(config)
class TestSelfManagedDeploymentsAreFirstClass:
"""mongod serves $vectorSearch identically whether mongot runs under Atlas or beside a
self-managed deployment, so an operator without an Atlas account has to be able to act on
every message. Guidance that only names Atlas remedies sends them looking for an IP access
list and a paused cluster that do not exist in their deployment."""
def _config_that_fails_to_connect(self, error):
def factory(_key):
raise error
return MongoDBVectorStoreConfig(
embedding_fn=FakeEmbeddingFn([0.1, 0.2, 0.3]), sync_client_factory=factory
)
def test_a_plain_mongodb_uri_without_srv_or_credentials_is_accepted(self):
params = _MongoDBSearchParams.model_validate(
{**BASE_PARAMS, "mongodb_connection_string": "mongodb://mongod.internal:27017"}
)
assert params.require_connection_string() == "mongodb://mongod.internal:27017"
def test_an_unreachable_deployment_names_a_self_managed_remedy(self):
from pymongo.errors import ServerSelectionTimeoutError
config = self._config_that_fails_to_connect(ServerSelectionTimeoutError("connection refused"))
with pytest.raises(Timeout) as excinfo:
_search(config)
assert "self-managed" in str(excinfo.value)
assert "host or port" in str(excinfo.value)
def test_a_refused_connection_names_a_self_managed_remedy(self):
from pymongo.errors import ConnectionFailure
config = self._config_that_fails_to_connect(ConnectionFailure("connection closed"))
with pytest.raises(BadRequestError) as excinfo:
_search(config)
assert "self-managed" in str(excinfo.value)
assert "mongod is listening" in str(excinfo.value)
def test_an_unresolvable_hostname_names_a_self_managed_remedy(self):
from pymongo.errors import ConfigurationError
config = self._config_that_fails_to_connect(ConfigurationError("The DNS query name does not exist"))
with pytest.raises(BadRequestError) as excinfo:
_search(config)
assert "self-managed" in str(excinfo.value)
def test_the_missing_index_message_does_not_claim_atlas(self):
message = str(missing_index_error(INDEX, "sample_mflix", "embedded_movies"))
assert "MongoDB Vector Search index" in message
assert "Atlas" not in message
def test_the_not_ready_message_does_not_claim_atlas(self):
message = str(index_not_ready_error(INDEX, "sample_mflix", "embedded_movies", "PENDING"))
assert "MongoDB Vector Search index" in message
assert "Atlas" not in message
def test_the_search_only_refusal_does_not_claim_atlas(self):
config = MongoDBVectorStoreConfig()
with pytest.raises(BadRequestError) as excinfo:
config.transform_create_vector_store_request({}, api_base="")
assert "Atlas" not in str(excinfo.value)
def test_a_dimension_mismatch_does_not_claim_atlas(self):
from pymongo.errors import OperationFailure
error = OperationFailure("vector field is indexed with 128 dimensions but queried with 256")
translated = translate_mongo_error(error, index_name=INDEX, database="db", collection="c")
assert "Atlas" not in str(translated)
assert "dimensions the index was built for" in str(translated)
def test_an_uncovered_embedding_field_does_not_claim_atlas(self):
from pymongo.errors import OperationFailure
error = OperationFailure("embedding is not indexed as vector")
translated = translate_mongo_error(error, index_name=INDEX, database="db", collection="c")
assert "MongoDB Vector Search index does not cover" in str(translated)
assert "Atlas" not in str(translated)
def test_a_self_managed_auth_failure_is_still_recognised_by_code_18(self):
from pymongo.errors import OperationFailure
error = OperationFailure("Authentication failed.", code=18, details={"code": 18})
translated = translate_mongo_error(error, index_name=INDEX, database="db", collection="c")
assert isinstance(translated, BadRequestError)
assert "rejected the credentials" in str(translated)
class TestUnescapedCredentialsAreDiagnosed:
"""Self-managed deployments usually carry a generated password, so '@', '/', ':' and '%' in one
are routine. pymongo reports those as a port, a database name or an RFC 3986 complaint, none of
which points the operator at their password, so each has to be named for what it is. The errors
here come from pymongo's real parser rather than a synthetic stand-in."""
@staticmethod
def _real_parse_error(uri):
from pymongo import MongoClient
try:
MongoClient(uri, serverSelectionTimeoutMS=1)
except Exception as e:
return e
raise AssertionError(f"expected {uri!r} to fail parsing")
def _translated(self, uri):
return translate_mongo_error(
self._real_parse_error(uri), index_name=INDEX, database="db", collection="c"
)
@pytest.mark.parametrize(
"uri",
[
"mongodb://user:pa@ss@host:27017/",
"mongodb://user:pa:ss@host:27017/",
"mongodb://user:pa%ss@host:27017/",
"mongodb://user@x:pw@host:27017/",
],
)
def test_rfc_3986_complaints_tell_the_operator_to_encode_the_password(self, uri):
translated = self._translated(uri)
assert isinstance(translated, BadRequestError)
assert "percent-encoded per RFC 3986" in str(translated)
@pytest.mark.parametrize(
"uri",
["mongodb://user:pa/ss@host:27017/", "mongodb://user/x:pw@host:27017/"],
)
def test_a_slash_in_the_credentials_is_not_reported_as_a_database_name(self, uri):
translated = self._translated(uri)
assert isinstance(translated, BadRequestError)
assert "percent-encoded per RFC 3986" in str(translated)
def test_an_unusable_port_names_the_host_and_port_not_the_database(self):
translated = self._translated("mongodb://host:99999/")
assert isinstance(translated, BadRequestError)
assert "host and port" in str(translated)
def test_a_genuinely_bad_database_name_still_mentions_the_uri_path(self):
translated = self._translated("mongodb://host:27017/has space")
assert isinstance(translated, BadRequestError)
assert "database name in the URI path" in str(translated)