From b2f634a80cbe478e803244ea51f6f200e729c3fb Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 18 Aug 2025 17:57:13 -0500 Subject: [PATCH 1/3] PYTHON-5492 Fix handling of MaxTimeMS message --- pymongo/asynchronous/encryption.py | 2 +- pymongo/asynchronous/pool.py | 3 +-- pymongo/asynchronous/server.py | 7 +----- pymongo/helpers_shared.py | 34 ++++++++++++++++++++++++++++++ pymongo/pool_shared.py | 27 +----------------------- pymongo/synchronous/encryption.py | 2 +- pymongo/synchronous/pool.py | 3 +-- pymongo/synchronous/server.py | 7 +----- test/asynchronous/test_cursor.py | 16 ++++++++++++++ test/asynchronous/test_pooling.py | 1 + test/test_cursor.py | 16 ++++++++++++++ test/test_pooling.py | 1 + 12 files changed, 75 insertions(+), 44 deletions(-) diff --git a/pymongo/asynchronous/encryption.py b/pymongo/asynchronous/encryption.py index 149cb3ac85..4f7d55cd06 100644 --- a/pymongo/asynchronous/encryption.py +++ b/pymongo/asynchronous/encryption.py @@ -75,12 +75,12 @@ NetworkTimeout, ServerSelectionTimeoutError, ) +from pymongo.helpers_shared import _get_timeout_details from pymongo.network_layer import async_socket_sendall from pymongo.operations import UpdateOne from pymongo.pool_options import PoolOptions from pymongo.pool_shared import ( _async_configured_socket, - _get_timeout_details, _raise_connection_failure, ) from pymongo.read_concern import ReadConcern diff --git a/pymongo/asynchronous/pool.py b/pymongo/asynchronous/pool.py index e215cafdc1..196ec9040f 100644 --- a/pymongo/asynchronous/pool.py +++ b/pymongo/asynchronous/pool.py @@ -58,6 +58,7 @@ WaitQueueTimeoutError, ) from pymongo.hello import Hello, HelloCompat +from pymongo.helpers_shared import _get_timeout_details, format_timeout_details from pymongo.lock import ( _async_cond_wait, _async_create_condition, @@ -79,9 +80,7 @@ SSLErrors, _CancellationContext, _configured_protocol_interface, - _get_timeout_details, _raise_connection_failure, - format_timeout_details, ) from pymongo.read_preferences import ReadPreference from pymongo.server_api import _add_to_command diff --git a/pymongo/asynchronous/server.py b/pymongo/asynchronous/server.py index cef8bd011c..cc515629d0 100644 --- a/pymongo/asynchronous/server.py +++ b/pymongo/asynchronous/server.py @@ -38,7 +38,6 @@ _SDAMStatusMessage, ) from pymongo.message import _convert_exception, _GetMore, _OpMsg, _Query -from pymongo.pool_shared import _get_timeout_details, format_timeout_details from pymongo.response import PinnedResponse, Response if TYPE_CHECKING: @@ -225,11 +224,7 @@ async def run_operation( if use_cmd: first = docs[0] await operation.client._process_response(first, operation.session) # type: ignore[misc, arg-type] - # Append timeout details to MaxTimeMSExpired responses. - if first.get("code") == 50: - timeout_details = _get_timeout_details(conn.opts) # type:ignore[has-type] - first["errmsg"] += format_timeout_details(timeout_details) # type:ignore[index] - _check_command_response(first, conn.max_wire_version) + _check_command_response(first, conn.max_wire_version, pool_opts=conn.opts) except Exception as exc: duration = datetime.now() - start if isinstance(exc, (NotPrimaryError, OperationFailure)): diff --git a/pymongo/helpers_shared.py b/pymongo/helpers_shared.py index 9646c0691a..c3611df7c8 100644 --- a/pymongo/helpers_shared.py +++ b/pymongo/helpers_shared.py @@ -47,6 +47,7 @@ if TYPE_CHECKING: from pymongo.cursor_shared import _Hint from pymongo.operations import _IndexList + from pymongo.pool_options import PoolOptions from pymongo.typings import _DocumentOut @@ -108,6 +109,34 @@ } +def _get_timeout_details(options: PoolOptions) -> dict[str, float]: + from pymongo import _csot + + details = {} + timeout = _csot.get_timeout() + socket_timeout = options.socket_timeout + connect_timeout = options.connect_timeout + if timeout: + details["timeoutMS"] = timeout * 1000 + if socket_timeout and not timeout: + details["socketTimeoutMS"] = socket_timeout * 1000 + if connect_timeout: + details["connectTimeoutMS"] = connect_timeout * 1000 + return details + + +def format_timeout_details(details: Optional[dict[str, float]]) -> str: + result = "" + if details: + result += " (configured timeouts:" + for timeout in ["socketTimeoutMS", "timeoutMS", "connectTimeoutMS"]: + if timeout in details: + result += f" {timeout}: {details[timeout]}ms," + result = result[:-1] + result += ")" + return result + + def _gen_index_name(keys: _IndexList) -> str: """Generate an index name from the set of fields it is over.""" return "_".join(["{}_{}".format(*item) for item in keys]) @@ -188,6 +217,7 @@ def _check_command_response( max_wire_version: Optional[int], allowable_errors: Optional[Container[Union[int, str]]] = None, parse_write_concern_error: bool = False, + pool_opts: Optional[PoolOptions] = None, ) -> None: """Check the response to a command for errors.""" if "ok" not in response: @@ -243,6 +273,10 @@ def _check_command_response( if code in (11000, 11001, 12582): raise DuplicateKeyError(errmsg, code, response, max_wire_version) elif code == 50: + # Append timeout details to MaxTimeMSExpired responses. + if pool_opts: + timeout_details = _get_timeout_details(pool_opts) + errmsg += format_timeout_details(timeout_details) raise ExecutionTimeout(errmsg, code, response, max_wire_version) elif code == 43: raise CursorNotFound(errmsg, code, response, max_wire_version) diff --git a/pymongo/pool_shared.py b/pymongo/pool_shared.py index 905f1a4d18..ac562af542 100644 --- a/pymongo/pool_shared.py +++ b/pymongo/pool_shared.py @@ -36,6 +36,7 @@ NetworkTimeout, _CertificateError, ) +from pymongo.helpers_shared import _get_timeout_details, format_timeout_details from pymongo.network_layer import AsyncNetworkingInterface, NetworkingInterface, PyMongoProtocol from pymongo.pool_options import PoolOptions from pymongo.ssl_support import PYSSLError, SSLError, _has_sni @@ -149,32 +150,6 @@ def _raise_connection_failure( raise AutoReconnect(msg) from error -def _get_timeout_details(options: PoolOptions) -> dict[str, float]: - details = {} - timeout = _csot.get_timeout() - socket_timeout = options.socket_timeout - connect_timeout = options.connect_timeout - if timeout: - details["timeoutMS"] = timeout * 1000 - if socket_timeout and not timeout: - details["socketTimeoutMS"] = socket_timeout * 1000 - if connect_timeout: - details["connectTimeoutMS"] = connect_timeout * 1000 - return details - - -def format_timeout_details(details: Optional[dict[str, float]]) -> str: - result = "" - if details: - result += " (configured timeouts:" - for timeout in ["socketTimeoutMS", "timeoutMS", "connectTimeoutMS"]: - if timeout in details: - result += f" {timeout}: {details[timeout]}ms," - result = result[:-1] - result += ")" - return result - - class _CancellationContext: def __init__(self) -> None: self._cancelled = False diff --git a/pymongo/synchronous/encryption.py b/pymongo/synchronous/encryption.py index ba304e7bd3..d9aebf5ccd 100644 --- a/pymongo/synchronous/encryption.py +++ b/pymongo/synchronous/encryption.py @@ -70,12 +70,12 @@ NetworkTimeout, ServerSelectionTimeoutError, ) +from pymongo.helpers_shared import _get_timeout_details from pymongo.network_layer import sendall from pymongo.operations import UpdateOne from pymongo.pool_options import PoolOptions from pymongo.pool_shared import ( _configured_socket, - _get_timeout_details, _raise_connection_failure, ) from pymongo.read_concern import ReadConcern diff --git a/pymongo/synchronous/pool.py b/pymongo/synchronous/pool.py index 4ea5cb1c1e..f7f6a26c68 100644 --- a/pymongo/synchronous/pool.py +++ b/pymongo/synchronous/pool.py @@ -55,6 +55,7 @@ WaitQueueTimeoutError, ) from pymongo.hello import Hello, HelloCompat +from pymongo.helpers_shared import _get_timeout_details, format_timeout_details from pymongo.lock import ( _cond_wait, _create_condition, @@ -76,9 +77,7 @@ SSLErrors, _CancellationContext, _configured_socket_interface, - _get_timeout_details, _raise_connection_failure, - format_timeout_details, ) from pymongo.read_preferences import ReadPreference from pymongo.server_api import _add_to_command diff --git a/pymongo/synchronous/server.py b/pymongo/synchronous/server.py index 6651f63a30..59316cc714 100644 --- a/pymongo/synchronous/server.py +++ b/pymongo/synchronous/server.py @@ -37,7 +37,6 @@ _SDAMStatusMessage, ) from pymongo.message import _convert_exception, _GetMore, _OpMsg, _Query -from pymongo.pool_shared import _get_timeout_details, format_timeout_details from pymongo.response import PinnedResponse, Response from pymongo.synchronous.helpers import _handle_reauth @@ -225,11 +224,7 @@ def run_operation( if use_cmd: first = docs[0] operation.client._process_response(first, operation.session) # type: ignore[misc, arg-type] - # Append timeout details to MaxTimeMSExpired responses. - if first.get("code") == 50: - timeout_details = _get_timeout_details(conn.opts) # type:ignore[has-type] - first["errmsg"] += format_timeout_details(timeout_details) # type:ignore[index] - _check_command_response(first, conn.max_wire_version) + _check_command_response(first, conn.max_wire_version, pool_opts=conn.opts) except Exception as exc: duration = datetime.now() - start if isinstance(exc, (NotPrimaryError, OperationFailure)): diff --git a/test/asynchronous/test_cursor.py b/test/asynchronous/test_cursor.py index e7da40fa19..08da82762c 100644 --- a/test/asynchronous/test_cursor.py +++ b/test/asynchronous/test_cursor.py @@ -43,6 +43,7 @@ from bson import decode_all from bson.code import Code +from bson.raw_bson import RawBSONDocument from pymongo import ASCENDING, DESCENDING from pymongo.asynchronous.cursor import AsyncCursor, CursorType from pymongo.asynchronous.helpers import anext @@ -199,6 +200,21 @@ async def test_max_time_ms(self): finally: await client.admin.command("configureFailPoint", "maxTimeAlwaysTimeOut", mode="off") + async def test_maxtime_ms_message(self): + db = self.db + await db.t.insert_one({"x": 1}) + with self.assertRaises(Exception) as error: + await db.t.find_one({"$where": delay(2)}, max_time_ms=1) + + self.assertIn("(configured timeouts: connectTimeoutMS: 20000.0ms", str(error.exception)) + + client = await self.async_rs_client(document_class=RawBSONDocument) + await client.db.t.insert_one({"x": 1}) + with self.assertRaises(Exception) as error: + await client.db.t.find_one({"$where": delay(2)}, max_time_ms=1) + + self.assertIn("(configured timeouts: connectTimeoutMS: 20000.0ms", str(error.exception)) + async def test_max_await_time_ms(self): db = self.db await db.pymongo_test.drop() diff --git a/test/asynchronous/test_pooling.py b/test/asynchronous/test_pooling.py index 3193d9e3d5..3473b38835 100644 --- a/test/asynchronous/test_pooling.py +++ b/test/asynchronous/test_pooling.py @@ -24,6 +24,7 @@ from test.asynchronous.utils import async_get_pool, async_joinall, flaky from bson.codec_options import DEFAULT_CODEC_OPTIONS +from bson.raw_bson import RawBSONDocument from bson.son import SON from pymongo import AsyncMongoClient, message, timeout from pymongo.errors import AutoReconnect, ConnectionFailure, DuplicateKeyError diff --git a/test/test_cursor.py b/test/test_cursor.py index 9a4fb86e93..b63638bfab 100644 --- a/test/test_cursor.py +++ b/test/test_cursor.py @@ -43,6 +43,7 @@ from bson import decode_all from bson.code import Code +from bson.raw_bson import RawBSONDocument from pymongo import ASCENDING, DESCENDING from pymongo.collation import Collation from pymongo.errors import ExecutionTimeout, InvalidOperation, OperationFailure, PyMongoError @@ -197,6 +198,21 @@ def test_max_time_ms(self): finally: client.admin.command("configureFailPoint", "maxTimeAlwaysTimeOut", mode="off") + def test_maxtime_ms_message(self): + db = self.db + db.t.insert_one({"x": 1}) + with self.assertRaises(Exception) as error: + db.t.find_one({"$where": delay(2)}, max_time_ms=1) + + self.assertIn("(configured timeouts: connectTimeoutMS: 20000.0ms", str(error.exception)) + + client = self.rs_client(document_class=RawBSONDocument) + client.db.t.insert_one({"x": 1}) + with self.assertRaises(Exception) as error: + client.db.t.find_one({"$where": delay(2)}, max_time_ms=1) + + self.assertIn("(configured timeouts: connectTimeoutMS: 20000.0ms", str(error.exception)) + def test_max_await_time_ms(self): db = self.db db.pymongo_test.drop() diff --git a/test/test_pooling.py b/test/test_pooling.py index cb5b206996..ff503e13d6 100644 --- a/test/test_pooling.py +++ b/test/test_pooling.py @@ -24,6 +24,7 @@ from test.utils import flaky, get_pool, joinall from bson.codec_options import DEFAULT_CODEC_OPTIONS +from bson.raw_bson import RawBSONDocument from bson.son import SON from pymongo import MongoClient, message, timeout from pymongo.errors import AutoReconnect, ConnectionFailure, DuplicateKeyError From 1a364eefbd8a190dc71fb7154f64002c86f90fcd Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 18 Aug 2025 17:58:05 -0500 Subject: [PATCH 2/3] remove unused import --- test/asynchronous/test_pooling.py | 1 - test/test_pooling.py | 1 - 2 files changed, 2 deletions(-) diff --git a/test/asynchronous/test_pooling.py b/test/asynchronous/test_pooling.py index 3473b38835..3193d9e3d5 100644 --- a/test/asynchronous/test_pooling.py +++ b/test/asynchronous/test_pooling.py @@ -24,7 +24,6 @@ from test.asynchronous.utils import async_get_pool, async_joinall, flaky from bson.codec_options import DEFAULT_CODEC_OPTIONS -from bson.raw_bson import RawBSONDocument from bson.son import SON from pymongo import AsyncMongoClient, message, timeout from pymongo.errors import AutoReconnect, ConnectionFailure, DuplicateKeyError diff --git a/test/test_pooling.py b/test/test_pooling.py index ff503e13d6..cb5b206996 100644 --- a/test/test_pooling.py +++ b/test/test_pooling.py @@ -24,7 +24,6 @@ from test.utils import flaky, get_pool, joinall from bson.codec_options import DEFAULT_CODEC_OPTIONS -from bson.raw_bson import RawBSONDocument from bson.son import SON from pymongo import MongoClient, message, timeout from pymongo.errors import AutoReconnect, ConnectionFailure, DuplicateKeyError From 4fdd9c52aa0f310beac606d6d65e9d6a11fd3d18 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 18 Aug 2025 17:59:10 -0500 Subject: [PATCH 3/3] typing --- pymongo/asynchronous/server.py | 2 +- pymongo/synchronous/server.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pymongo/asynchronous/server.py b/pymongo/asynchronous/server.py index cc515629d0..f212306174 100644 --- a/pymongo/asynchronous/server.py +++ b/pymongo/asynchronous/server.py @@ -224,7 +224,7 @@ async def run_operation( if use_cmd: first = docs[0] await operation.client._process_response(first, operation.session) # type: ignore[misc, arg-type] - _check_command_response(first, conn.max_wire_version, pool_opts=conn.opts) + _check_command_response(first, conn.max_wire_version, pool_opts=conn.opts) # type:ignore[has-type] except Exception as exc: duration = datetime.now() - start if isinstance(exc, (NotPrimaryError, OperationFailure)): diff --git a/pymongo/synchronous/server.py b/pymongo/synchronous/server.py index 59316cc714..f57420918b 100644 --- a/pymongo/synchronous/server.py +++ b/pymongo/synchronous/server.py @@ -224,7 +224,7 @@ def run_operation( if use_cmd: first = docs[0] operation.client._process_response(first, operation.session) # type: ignore[misc, arg-type] - _check_command_response(first, conn.max_wire_version, pool_opts=conn.opts) + _check_command_response(first, conn.max_wire_version, pool_opts=conn.opts) # type:ignore[has-type] except Exception as exc: duration = datetime.now() - start if isinstance(exc, (NotPrimaryError, OperationFailure)):