44import pytest
55from urllib3 import Timeout
66
7+ import crate .client .exceptions
78from crate .client import connect
89from crate .client .connection import Connection
910from crate .client .exceptions import ProgrammingError
1213from .settings import crate_host
1314
1415
16+ class _FakeClient :
17+ """
18+ Minimal stand-in for Client that lets tests control server_infos.
19+ """
20+
21+ def __init__ (self , servers , server_infos_fn ):
22+ self ._servers = list (servers )
23+ self ._server_infos_fn = server_infos_fn
24+
25+ @property
26+ def active_servers (self ):
27+ return list (self ._servers )
28+
29+ def server_infos (self , server ):
30+ return self ._server_infos_fn (server )
31+
32+
33+ def _bare_conn (client ):
34+ """
35+ Create a Connection that bypasses __init__.
36+ """
37+
38+ conn = Connection .__new__ (Connection )
39+ conn .client = client
40+ return conn
41+
42+
43+ def test_invalid_server_address ():
44+ client = Client (servers = "localhost:1234" )
45+ with pytest .raises (crate .client .exceptions .ConnectionError ) as excinfo :
46+ connect (client = client )
47+ assert excinfo .match ("Server not available" )
48+
49+
1550def test_lowest_server_version ():
1651 """
1752 Verify the lowest server version is correctly set.
@@ -55,10 +90,13 @@ def test_connection_closes_access():
5590
5691def test_connection_closes_context_manager ():
5792 """Verify that the context manager of the client closes the connection"""
58- with patch .object (connect , "close" , autospec = True ) as close_fn :
59- with connect ():
60- pass
61- close_fn .assert_called_once ()
93+ with patch .object (
94+ Client , "server_infos" , return_value = (None , None , "0.0.0" )
95+ ):
96+ with patch .object (connect , "close" , autospec = True ) as close_fn :
97+ with connect ():
98+ pass
99+ close_fn .assert_called_once ()
62100
63101
64102def test_invalid_server_version ():
@@ -78,8 +116,11 @@ def test_context_manager():
78116 """
79117 close_method = "crate.client.http.Client.close"
80118 with patch (close_method , return_value = MagicMock ()) as close_func :
81- with connect ("localhost:4200" ) as conn :
82- assert not conn ._closed
119+ with patch .object (
120+ Client , "server_infos" , return_value = (None , None , "0.0.0" )
121+ ):
122+ with connect ("localhost:4200" ) as conn :
123+ assert not conn ._closed
83124
84125 assert conn ._closed
85126 # Checks that the close method of the client
@@ -115,7 +156,10 @@ def test_default_repr():
115156 """
116157 Verify default repr dunder method.
117158 """
118- conn = connect ()
159+ with patch .object (
160+ Client , "server_infos" , return_value = (None , None , "0.0.0" )
161+ ):
162+ conn = connect ()
119163 assert repr (conn ) == "<Connection <Client ['http://127.0.0.1:4200']>>"
120164
121165
@@ -132,7 +176,10 @@ def test_with_timezone():
132176 """
133177
134178 tz_mst = datetime .timezone (datetime .timedelta (hours = 7 ), name = "MST" )
135- connection = connect ("localhost:4200" , time_zone = tz_mst )
179+ with patch .object (
180+ Client , "server_infos" , return_value = (None , None , "0.0.0" )
181+ ):
182+ connection = connect ("localhost:4200" , time_zone = tz_mst )
136183 cursor = connection .cursor ()
137184
138185 assert cursor .time_zone .tzname (None ) == "MST"
@@ -148,22 +195,125 @@ def test_timeout_float():
148195 """
149196 Verify setting the timeout value as a scalar (float) works.
150197 """
151- with connect ("localhost:4200" , timeout = 2.42 ) as conn :
152- assert conn .client ._pool_kw ["timeout" ] == 2.42
198+ with patch .object (
199+ Client , "server_infos" , return_value = (None , None , "0.0.0" )
200+ ):
201+ with connect ("localhost:4200" , timeout = 2.42 ) as conn :
202+ assert conn .client ._pool_kw ["timeout" ] == 2.42
153203
154204
155205def test_timeout_string ():
156206 """
157207 Verify setting the timeout value as a scalar (string) works.
158208 """
159- with connect ("localhost:4200" , timeout = "2.42" ) as conn :
160- assert conn .client ._pool_kw ["timeout" ] == 2.42
209+ with patch .object (
210+ Client , "server_infos" , return_value = (None , None , "0.0.0" )
211+ ):
212+ with connect ("localhost:4200" , timeout = "2.42" ) as conn :
213+ assert conn .client ._pool_kw ["timeout" ] == 2.42
161214
162215
163216def test_timeout_object ():
164217 """
165218 Verify setting the timeout value as a Timeout object works.
166219 """
167220 timeout = Timeout (connect = 2.42 , read = 0.01 )
168- with connect ("localhost:4200" , timeout = timeout ) as conn :
169- assert conn .client ._pool_kw ["timeout" ] == timeout
221+ with patch .object (
222+ Client , "server_infos" , return_value = (None , None , "0.0.0" )
223+ ):
224+ with connect ("localhost:4200" , timeout = timeout ) as conn :
225+ assert conn .client ._pool_kw ["timeout" ] == timeout
226+
227+
228+ def test_partial_failure_raises ():
229+ """
230+ When some servers fail with ConnectionError and others produce an
231+ unparseable version string (triggering ValueError/InvalidVersion),
232+ the method must still raise rather than silently returning Version("0.0.0").
233+
234+ Risk: len(connection_errors) < server_count because only ConnectionError
235+ instances are counted, so the all-failed guard never fires.
236+ """
237+
238+ def server_infos (server ):
239+ if "4200" in server :
240+ raise crate .client .exceptions .ConnectionError (
241+ "Server not available"
242+ )
243+ # "bad-version" triggers InvalidVersion inside Version(), which is
244+ # caught by the second except branch and never appended to
245+ # connection_errors.
246+ return (None , None , "bad-version" )
247+
248+ client = _FakeClient (
249+ ["http://localhost:4200" , "http://localhost:4201" ],
250+ server_infos ,
251+ )
252+ conn = _bare_conn (client )
253+
254+ with pytest .raises (crate .client .exceptions .ConnectionError ):
255+ conn ._lowest_server_version ()
256+
257+
258+ def test_error_message_contains_individual_errors ():
259+ """
260+ When all servers fail with ConnectionError the raised exception message
261+ must contain each individual server's error text so operators can see
262+ which nodes are down.
263+ """
264+ msgs = {
265+ "http://localhost:4200" : "node-A refused connection" ,
266+ "http://localhost:4201" : "node-B timed out" ,
267+ }
268+
269+ def server_infos (server ):
270+ raise crate .client .exceptions .ConnectionError (msgs [server ])
271+
272+ client = _FakeClient (list (msgs ), server_infos )
273+ conn = _bare_conn (client )
274+
275+ with pytest .raises (crate .client .exceptions .ConnectionError ) as excinfo :
276+ conn ._lowest_server_version ()
277+
278+ msg = str (excinfo .value )
279+ assert "node-A refused connection" in msg
280+ assert "node-B timed out" in msg
281+
282+
283+ def test_active_servers_double_evaluation ():
284+ """
285+ active_servers is evaluated twice: once for len() (server_count) and once
286+ for the for-loop. If more servers appear between the two calls, every
287+ iterated server can fail with ConnectionError yet len(connection_errors)
288+ exceeds the stale server_count, causing the all-failed guard to miss.
289+
290+ """
291+
292+ class _UnstableClient :
293+ def __init__ (self ):
294+ self ._calls = 0
295+
296+ @property
297+ def active_servers (self ):
298+ self ._calls += 1
299+ if self ._calls == 1 :
300+ # First access: len() call — reports 2 servers.
301+ return ["http://localhost:4200" , "http://localhost:4201" ]
302+ # Second access: for-loop — a third server appeared concurrently.
303+ return [
304+ "http://localhost:4200" ,
305+ "http://localhost:4201" ,
306+ "http://localhost:4202" ,
307+ ]
308+
309+ def server_infos (self , server ):
310+ raise crate .client .exceptions .ConnectionError (
311+ "Server not available"
312+ )
313+
314+ conn = _bare_conn (_UnstableClient ())
315+
316+ # All 3 iterated servers fail, but server_count=2 (stale).
317+ # 3 != 2 → guard never fires → silently returns Version("0.0.0").
318+ with pytest .raises (crate .client .exceptions .ConnectionError ):
319+ conn ._lowest_server_version ()
0 commit comments