Skip to content

Commit d1cf626

Browse files
committed
fix(protocol): defer CONNECTED state until auth completes and unify start_async() semantics
Addresses #594 Addresses #727
1 parent e594cf8 commit d1cf626

5 files changed

Lines changed: 165 additions & 34 deletions

File tree

‎docs/async_usage.rst‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,7 @@ that you pass in the appropriate handler, the default handler is
5252
Asynchronous Callbacks
5353
======================
5454

55-
All kazoo `_async` methods except for
56-
:meth:`~kazoo.client.KazooClient.start_async` return an
55+
All kazoo `_async` methods return an
5756
:class:`~kazoo.interfaces.IAsyncResult` instance. These instances allow
5857
you to see when a result is ready, or chain one or more callback
5958
functions to the result that will be called when it's ready.

‎kazoo/client.py‎

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
)
6363
from kazoo.protocol.states import (
6464
Callback,
65+
CLOSED_STATES,
6566
EventType,
6667
KazooState,
6768
KeeperState,
@@ -88,11 +89,6 @@
8889
from kazoo.handlers.gevent import SequentialGeventHandler
8990

9091

91-
CLOSED_STATES = (
92-
KeeperState.EXPIRED_SESSION,
93-
KeeperState.AUTH_FAILED,
94-
KeeperState.CLOSED,
95-
)
9692
ENVI_VERSION = re.compile(r"([\d\.]*).*", re.DOTALL)
9793
ENVI_VERSION_KEY = "zookeeper.version"
9894
log = logging.getLogger(__name__)
@@ -394,6 +390,8 @@ def __init__(
394390
self._stopped = self.handler.event_object()
395391
self._stopped.set()
396392
self._writer_stopped.set()
393+
self._conn_result: IAsyncResult | None = None
394+
self._auth_error: Exception | None = None
397395

398396
# FIXME This is kind of gross but we need to set these to something so
399397
# that the type checker will understand that they are set by the time
@@ -722,10 +720,26 @@ def _session_callback(self, state: KeeperState) -> None:
722720
"Zookeeper connection established, " "state: %s", state
723721
)
724722
self._live.set()
723+
if self._conn_result is not None and not self._conn_result.ready():
724+
self._conn_result.set(True)
725725
self._make_state_change(KazooState.CONNECTED)
726726
elif state in CLOSED_STATES:
727727
self.logger.info("Zookeeper session closed, state: %s", state)
728728
self._live.clear()
729+
if self._conn_result is not None and not self._conn_result.ready():
730+
if state == KeeperState.AUTH_FAILED:
731+
if isinstance(self._auth_error, AuthFailedError):
732+
err: Exception = self._auth_error
733+
elif self._auth_error is not None:
734+
err = AuthFailedError("Authentication failed")
735+
err.__cause__ = self._auth_error
736+
else:
737+
err = AuthFailedError("Authentication failed")
738+
self._conn_result.set_exception(err)
739+
elif state == KeeperState.CLOSED:
740+
self._conn_result.set_exception(
741+
ConnectionClosedError("Connection closed")
742+
)
729743
self._make_state_change(KazooState.LOST)
730744
self._notify_pending(state)
731745
self._reset()
@@ -766,6 +780,10 @@ def _notify_pending(self, state: KeeperState) -> None:
766780

767781
def _safe_close(self) -> None:
768782
self.handler.stop()
783+
if self._conn_result is not None and not self._conn_result.ready():
784+
self._conn_result.set_exception(
785+
ConnectionClosedError("Connection closed")
786+
)
769787
timeout = self._session_timeout // 1000
770788
if timeout < 10:
771789
timeout = 10
@@ -823,33 +841,49 @@ def start(self, timeout: float = 15.0) -> None:
823841
:raises: :attr:`~kazoo.interfaces.IHandler.timeout_exception`
824842
if the connection wasn't established within `timeout`
825843
seconds.
844+
:raises: :exc:`~kazoo.exceptions.AuthFailedError` if authentication
845+
failed during connection establishment.
826846
827847
"""
828-
event = self.start_async()
829-
event.wait(timeout=timeout)
830-
if not self.connected:
831-
# We time-out, ensure we are disconnected
848+
if self._live.is_set():
849+
return
850+
851+
async_result = self.start_async()
852+
try:
853+
async_result.get(timeout=timeout)
854+
except Exception:
832855
self.stop()
833856
self.close()
834-
raise self.handler.timeout_exception("Connection time-out")
857+
raise
835858

836859
if self.chroot and not self.exists("/"):
837860
warnings.warn(
838861
"No chroot path exists, the chroot path "
839862
"should be created before normal use."
840863
)
841864

842-
def start_async(self) -> Event:
865+
def start_async(self) -> IAsyncResult:
843866
"""Asynchronously initiate connection to ZK.
844867
845-
:returns: An event object that can be checked to see if the
846-
connection is alive.
847-
:rtype: :class:`~threading.Event` compatible object.
868+
:returns: An :class:`~kazoo.interfaces.IAsyncResult` instance that
869+
resolves to `True` when connected or fails with an
870+
exception on terminal failure.
848871
849872
"""
850-
# If we're already connected, ignore
873+
# If we're already connected, return an already-completed result
851874
if self._live.is_set():
852-
return self._live
875+
if self._conn_result is None:
876+
# Defensive fallback to catch poor mocking in KazooClient
877+
# internals (tests or mocks that manually set self._live.set()
878+
# without going through start_async()). In production,
879+
# self._conn_result is always initialized during start_async()
880+
# and should *never* be None while self._live is set.
881+
self._conn_result = self.handler.async_result()
882+
self._conn_result.set(True)
883+
return self._conn_result
884+
885+
if self._conn_result is not None and not self._conn_result.ready():
886+
return self._conn_result
853887

854888
# Make sure we're safely closed
855889
self._safe_close()
@@ -858,13 +892,14 @@ def start_async(self) -> Event:
858892
# thread indicator
859893
self._stopped.clear()
860894
self._writer_stopped.clear()
895+
self._conn_result = self.handler.async_result()
861896

862897
# Start the handler
863898
self.handler.start()
864899

865900
# Start the connection
866901
self._connection.start()
867-
return self._live
902+
return self._conn_result
868903

869904
def stop(self) -> None:
870905
"""Gracefully stop this Zookeeper session.
@@ -884,11 +919,8 @@ def stop(self) -> None:
884919
self._stopped.set()
885920
self._queue.append((CloseInstance, cast("IAsyncResult", None)))
886921
try:
887-
# This assert should never fail since the connection should
888-
# have been started but I'm not sure how to persaude mypy of that
889-
self._connection._write_sock.send( # type: ignore[union-attr]
890-
b"\0"
891-
)
922+
if self._connection._write_sock is not None:
923+
self._connection._write_sock.send(b"\0")
892924
finally:
893925
self._safe_close()
894926

‎kazoo/protocol/connection.py‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
)
5151
from kazoo.protocol.states import (
5252
Callback,
53+
CLOSED_STATES,
5354
KeeperState,
5455
WatchedEvent,
5556
EVENT_TYPE_MAP,
@@ -658,7 +659,8 @@ def zk_loop(self) -> None:
658659
)
659660
finally:
660661
self.connection_stopped.set()
661-
self.client._session_callback(KeeperState.CLOSED)
662+
if self.client._state not in CLOSED_STATES:
663+
self.client._session_callback(KeeperState.CLOSED)
662664
self.logger.log(BLATHER, "Connection stopped")
663665

664666
def _expand_client_hosts(self) -> list[tuple[str, str, int]]:
@@ -802,13 +804,15 @@ def _connect_attempt(
802804
except (AuthFailedError, SASLException) as err:
803805
retry.reset()
804806
self.logger.warning("AUTH_FAILED closing: %s", err)
807+
self.client._auth_error = err
805808
client._session_callback(KeeperState.AUTH_FAILED)
806809
return STOP_CONNECTING
807810
except SessionClosedRequireSaslError as err:
808811
retry.reset()
809812
self.logger.warning(
810813
"AUTH_FAILED closing (server requires SASL auth): %s", err
811814
)
815+
self.client._auth_error = err
812816
client._session_callback(KeeperState.AUTH_FAILED)
813817
return STOP_CONNECTING
814818
except SessionExpiredError:
@@ -908,13 +912,6 @@ def _connect(
908912
read_timeout,
909913
)
910914

911-
if connect_result.read_only:
912-
client._session_callback(KeeperState.CONNECTED_RO)
913-
self._ro_mode = iter(self._server_pinger())
914-
else:
915-
client._session_callback(KeeperState.CONNECTED)
916-
self._ro_mode = None
917-
918915
if self.sasl_options is not None:
919916
self._authenticate_with_sasl(host, connect_timeout / 1000.0)
920917

@@ -928,6 +925,13 @@ def _connect(
928925
if zxid:
929926
client.last_zxid = zxid
930927

928+
if connect_result.read_only:
929+
client._session_callback(KeeperState.CONNECTED_RO)
930+
self._ro_mode = iter(self._server_pinger())
931+
else:
932+
client._session_callback(KeeperState.CONNECTED)
933+
self._ro_mode = None
934+
931935
return read_timeout, connect_timeout
932936

933937
def _authenticate_with_sasl(self, host: str, timeout: float) -> None:

‎kazoo/protocol/states.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ class KeeperState(str, Enum):
7676
EXPIRED_SESSION = "EXPIRED_SESSION"
7777

7878

79+
CLOSED_STATES = (
80+
KeeperState.EXPIRED_SESSION,
81+
KeeperState.AUTH_FAILED,
82+
KeeperState.CLOSED,
83+
)
84+
85+
7986
# This is a (str, Enum) for backwards compatibility.
8087
class EventType(str, Enum):
8188
"""Zookeeper Event

‎kazoo/tests/test_client.py‎

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151

5252
if TYPE_CHECKING:
5353
from kazoo.testing.common import ManagedZooKeeper
54-
from kazoo.interfaces import IAsyncResult
54+
from kazoo.interfaces import Event, IAsyncResult
5555

5656

5757
class TestClientTransitions(KazooTestCase):
@@ -1325,7 +1325,7 @@ def test_session_callback_states(self) -> None:
13251325
# Test several state transitions
13261326
client._stopped.clear()
13271327
client.start_async = ( # type: ignore[method-assign]
1328-
lambda: threading.Event()
1328+
lambda: client.handler.async_result()
13291329
)
13301330
client._session_callback(KeeperState.CONNECTED)
13311331
assert client.state == KazooState.CONNECTED
@@ -1340,6 +1340,95 @@ def test_session_callback_states(self) -> None:
13401340
client._session_callback(-250) # type: ignore[unreachable]
13411341
assert client.state == KazooState.SUSPENDED
13421342

1343+
def test_start_fails_immediately_on_auth_failed(self) -> None:
1344+
client = KazooClient()
1345+
1346+
def fake_conn_start() -> None:
1347+
client._session_callback(KeeperState.AUTH_FAILED)
1348+
1349+
client._connection.start = ( # type: ignore[method-assign]
1350+
fake_conn_start
1351+
)
1352+
start_time = time.monotonic()
1353+
with pytest.raises(AuthFailedError):
1354+
client.start(timeout=10.0)
1355+
elapsed = time.monotonic() - start_time
1356+
assert elapsed < 1.0
1357+
1358+
def test_start_fails_immediately_with_underlying_auth_error(self) -> None:
1359+
client = KazooClient()
1360+
expected_err = AuthFailedError("custom auth error")
1361+
1362+
def fake_conn_start() -> None:
1363+
client._auth_error = expected_err
1364+
client._session_callback(KeeperState.AUTH_FAILED)
1365+
1366+
client._connection.start = ( # type: ignore[method-assign]
1367+
fake_conn_start
1368+
)
1369+
with pytest.raises(AuthFailedError) as exc_info:
1370+
client.start(timeout=10.0)
1371+
assert exc_info.value is expected_err
1372+
1373+
def test_start_async_triggers_on_auth_failed(self) -> None:
1374+
client = KazooClient()
1375+
expected_err = AuthFailedError("custom auth error")
1376+
1377+
def fake_conn_start() -> None:
1378+
client._auth_error = expected_err
1379+
client._session_callback(KeeperState.AUTH_FAILED)
1380+
1381+
client._connection.start = ( # type: ignore[method-assign]
1382+
fake_conn_start
1383+
)
1384+
res = client.start_async()
1385+
assert res.ready()
1386+
assert not res.successful()
1387+
assert res.exception is expected_err
1388+
assert res.wait(timeout=5.0)
1389+
with pytest.raises(AuthFailedError) as exc_info:
1390+
res.get()
1391+
assert exc_info.value is expected_err
1392+
1393+
def test_start_async_success(self) -> None:
1394+
client = KazooClient()
1395+
1396+
def fake_conn_start() -> None:
1397+
client._session_callback(KeeperState.CONNECTED)
1398+
1399+
client._connection.start = ( # type: ignore[method-assign]
1400+
fake_conn_start
1401+
)
1402+
res = client.start_async()
1403+
assert res.ready()
1404+
assert res.successful()
1405+
assert res.get() is True
1406+
assert client.connected
1407+
1408+
# Calling start_async when already connected returns completed result
1409+
res2 = client.start_async()
1410+
assert res2.ready()
1411+
assert res2.successful()
1412+
assert res2.get() is True
1413+
1414+
def test_start_async_recovers_from_expired_session(self) -> None:
1415+
client = KazooClient()
1416+
1417+
def fake_conn_start() -> None:
1418+
# First attempt receives EXPIRED_SESSION (not terminal)
1419+
client._session_callback(KeeperState.EXPIRED_SESSION)
1420+
# Second attempt receives CONNECTED
1421+
client._session_callback(KeeperState.CONNECTED)
1422+
1423+
client._connection.start = ( # type: ignore[method-assign]
1424+
fake_conn_start
1425+
)
1426+
res = client.start_async()
1427+
assert res.ready()
1428+
assert res.successful()
1429+
assert res.get() is True
1430+
assert client.connected
1431+
13431432

13441433
class TestCallbacks(KazooTestCase):
13451434
def test_async_result_callbacks_are_always_called(self) -> None:

0 commit comments

Comments
 (0)