Skip to content

Commit 06653c9

Browse files
authored
PYTHON-3926 Add more information to connection errors and timeouts (#1375)
1 parent 2825058 commit 06653c9

File tree

3 files changed

+129
-10
lines changed

3 files changed

+129
-10
lines changed

pymongo/common.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@
5656
if TYPE_CHECKING:
5757
from pymongo.client_session import ClientSession
5858

59-
6059
ORDERED_TYPES: Sequence[Type] = (SON, OrderedDict)
6160

6261
# Defaults until we connect to a server and get updated limits.
@@ -804,7 +803,6 @@ def validate_server_monitoring_mode(option: str, value: str) -> str:
804803
"waitqueuetimeoutms",
805804
]
806805

807-
808806
_AUTH_OPTIONS = frozenset(["authmechanismproperties"])
809807

810808

pymongo/pool.py

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,10 @@ def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None:
383383

384384

385385
def _raise_connection_failure(
386-
address: Any, error: Exception, msg_prefix: Optional[str] = None
386+
address: Any,
387+
error: Exception,
388+
msg_prefix: Optional[str] = None,
389+
timeout_details: Optional[dict[str, float]] = None,
387390
) -> NoReturn:
388391
"""Convert a socket.error to ConnectionFailure and raise it."""
389392
host, port = address
@@ -394,6 +397,8 @@ def _raise_connection_failure(
394397
msg = f"{host}: {error}"
395398
if msg_prefix:
396399
msg = msg_prefix + msg
400+
if "configured timeouts" not in msg:
401+
msg += format_timeout_details(timeout_details)
397402
if isinstance(error, socket.timeout):
398403
raise NetworkTimeout(msg) from error
399404
elif isinstance(error, SSLError) and "timed out" in str(error):
@@ -411,6 +416,32 @@ def _cond_wait(condition: threading.Condition, deadline: Optional[float]) -> boo
411416
return condition.wait(timeout)
412417

413418

419+
def _get_timeout_details(options: PoolOptions) -> dict[str, float]:
420+
details = {}
421+
timeout = _csot.get_timeout()
422+
socket_timeout = options.socket_timeout
423+
connect_timeout = options.connect_timeout
424+
if timeout:
425+
details["timeoutMS"] = timeout * 1000
426+
if socket_timeout and not timeout:
427+
details["socketTimeoutMS"] = socket_timeout * 1000
428+
if connect_timeout:
429+
details["connectTimeoutMS"] = connect_timeout * 1000
430+
return details
431+
432+
433+
def format_timeout_details(details: Optional[dict[str, float]]) -> str:
434+
result = ""
435+
if details:
436+
result += " (configured timeouts:"
437+
for timeout in ["socketTimeoutMS", "timeoutMS", "connectTimeoutMS"]:
438+
if timeout in details:
439+
result += f" {timeout}: {details[timeout]}ms,"
440+
result = result[:-1]
441+
result += ")"
442+
return result
443+
444+
414445
class PoolOptions:
415446
"""Read only connection pool options for a MongoClient.
416447
@@ -740,10 +771,15 @@ def apply_timeout(
740771
rtt = self.connect_rtt
741772
max_time_ms = timeout - rtt
742773
if max_time_ms < 0:
774+
timeout_details = _get_timeout_details(self.opts)
775+
formatted = format_timeout_details(timeout_details)
743776
# CSOT: raise an error without running the command since we know it will time out.
744-
errmsg = f"operation would exceed time limit, remaining timeout:{timeout:.5f} <= network round trip time:{rtt:.5f}"
777+
errmsg = f"operation would exceed time limit, remaining timeout:{timeout:.5f} <= network round trip time:{rtt:.5f} {formatted}"
745778
raise ExecutionTimeout(
746-
errmsg, 50, {"ok": 0, "errmsg": errmsg, "code": 50}, self.max_wire_version
779+
errmsg,
780+
50,
781+
{"ok": 0, "errmsg": errmsg, "code": 50},
782+
self.max_wire_version,
747783
)
748784
if cmd is not None:
749785
cmd["maxTimeMS"] = int(max_time_ms * 1000)
@@ -1131,7 +1167,8 @@ def _raise_connection_failure(self, error: BaseException) -> NoReturn:
11311167
self.close_conn(reason)
11321168
# SSLError from PyOpenSSL inherits directly from Exception.
11331169
if isinstance(error, (IOError, OSError, SSLError)):
1134-
_raise_connection_failure(self.address, error)
1170+
details = _get_timeout_details(self.opts)
1171+
_raise_connection_failure(self.address, error, timeout_details=details)
11351172
else:
11361173
raise
11371174

@@ -1255,7 +1292,8 @@ def _configured_socket(address: _Address, options: PoolOptions) -> Union[socket.
12551292
# We raise AutoReconnect for transient and permanent SSL handshake
12561293
# failures alike. Permanent handshake failures, like protocol
12571294
# mismatch, will be turned into ServerSelectionTimeoutErrors later.
1258-
_raise_connection_failure(address, exc, "SSL handshake failed: ")
1295+
details = _get_timeout_details(options)
1296+
_raise_connection_failure(address, exc, "SSL handshake failed: ", timeout_details=details)
12591297
if (
12601298
ssl_context.verify_mode
12611299
and not ssl_context.check_hostname
@@ -1553,7 +1591,8 @@ def connect(self, handler: Optional[_MongoClientErrorHandler] = None) -> Connect
15531591
)
15541592

15551593
if isinstance(error, (IOError, OSError, SSLError)):
1556-
_raise_connection_failure(self.address, error)
1594+
details = _get_timeout_details(self.opts)
1595+
_raise_connection_failure(self.address, error, timeout_details=details)
15571596

15581597
raise
15591598

@@ -1634,7 +1673,10 @@ def _raise_if_not_ready(self, emit_event: bool) -> None:
16341673
self.opts._event_listeners.publish_connection_check_out_failed(
16351674
self.address, ConnectionCheckOutFailedReason.CONN_ERROR
16361675
)
1637-
_raise_connection_failure(self.address, AutoReconnect("connection pool paused"))
1676+
details = _get_timeout_details(self.opts)
1677+
_raise_connection_failure(
1678+
self.address, AutoReconnect("connection pool paused"), timeout_details=details
1679+
)
16381680

16391681
def _get_conn(self, handler: Optional[_MongoClientErrorHandler] = None) -> Connection:
16401682
"""Get or create a Connection. Can raise ConnectionFailure."""

test/test_pooling.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@
2424

2525
from bson.codec_options import DEFAULT_CODEC_OPTIONS
2626
from bson.son import SON
27-
from pymongo import MongoClient, message
27+
from pymongo import MongoClient, message, timeout
2828
from pymongo.errors import AutoReconnect, ConnectionFailure, DuplicateKeyError
29+
from pymongo.hello import HelloCompat
2930

3031
sys.path[0:0] = [""]
3132

@@ -412,6 +413,84 @@ def find_one():
412413
# maxConnecting = unbounded: 30+ connections in ~0.140+ seconds
413414
print(len(pool.conns))
414415

416+
@client_context.require_failCommand_fail_point
417+
def test_csot_timeout_message(self):
418+
client = rs_or_single_client(appName="connectionTimeoutApp")
419+
# Mock a connection failing due to timeout.
420+
mock_connection_timeout = {
421+
"configureFailPoint": "failCommand",
422+
"mode": "alwaysOn",
423+
"data": {
424+
"blockConnection": True,
425+
"blockTimeMS": 1000,
426+
"failCommands": ["find"],
427+
"appName": "connectionTimeoutApp",
428+
},
429+
}
430+
431+
client.db.t.insert_one({"x": 1})
432+
433+
with self.fail_point(mock_connection_timeout):
434+
with self.assertRaises(Exception) as error:
435+
with timeout(0.5):
436+
client.db.t.find_one({"$where": delay(2)})
437+
438+
self.assertTrue("(configured timeouts: timeoutMS: 500.0ms" in str(error.exception))
439+
440+
@client_context.require_failCommand_fail_point
441+
def test_socket_timeout_message(self):
442+
client = rs_or_single_client(socketTimeoutMS=500, appName="connectionTimeoutApp")
443+
444+
# Mock a connection failing due to timeout.
445+
mock_connection_timeout = {
446+
"configureFailPoint": "failCommand",
447+
"mode": "alwaysOn",
448+
"data": {
449+
"blockConnection": True,
450+
"blockTimeMS": 1000,
451+
"failCommands": ["find"],
452+
"appName": "connectionTimeoutApp",
453+
},
454+
}
455+
456+
client.db.t.insert_one({"x": 1})
457+
458+
with self.fail_point(mock_connection_timeout):
459+
with self.assertRaises(Exception) as error:
460+
client.db.t.find_one({"$where": delay(2)})
461+
462+
self.assertTrue(
463+
"(configured timeouts: socketTimeoutMS: 500.0ms, connectTimeoutMS: 20000.0ms)"
464+
in str(error.exception)
465+
)
466+
467+
@client_context.require_failCommand_fail_point
468+
@client_context.require_version_min(
469+
4, 9, 0
470+
) # configureFailPoint does not allow failure on handshake before 4.9, fixed in SERVER-49336
471+
def test_connection_timeout_message(self):
472+
# Mock a connection failing due to timeout.
473+
mock_connection_timeout = {
474+
"configureFailPoint": "failCommand",
475+
"mode": "alwaysOn",
476+
"data": {
477+
"blockConnection": True,
478+
"blockTimeMS": 1000,
479+
"failCommands": [HelloCompat.LEGACY_CMD, "hello"],
480+
"appName": "connectionTimeoutApp",
481+
},
482+
}
483+
484+
with self.fail_point(mock_connection_timeout):
485+
with self.assertRaises(Exception) as error:
486+
client = rs_or_single_client(connectTimeoutMS=500, appName="connectionTimeoutApp")
487+
client.admin.command("ping")
488+
489+
self.assertTrue(
490+
"(configured timeouts: socketTimeoutMS: 500.0ms, connectTimeoutMS: 500.0ms)"
491+
in str(error.exception)
492+
)
493+
415494

416495
class TestPoolMaxSize(_TestPoolingBase):
417496
def test_max_pool_size(self):

0 commit comments

Comments
 (0)