Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 4 additions & 63 deletions nats-core/src/nats/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from urllib.parse import urlparse

import nkeys
from nats.client.connection import Connection, establish_connection
from nats.client.connection import Connection, complete_handshake, establish_connection
from nats.client.errors import (
MaxPayloadError,
NoRespondersError,
Expand All @@ -48,7 +48,6 @@
)
from nats.client.message import Headers, Message, Status
from nats.client.protocol.command import (
encode_connect,
encode_headers,
encode_hpub,
encode_ping,
Expand All @@ -57,7 +56,7 @@
encode_sub,
encode_unsub,
)
from nats.client.protocol.message import Err, Ok, ParseError, Pong, parse
from nats.client.protocol.message import ParseError, parse
from nats.client.protocol.types import (
ConnectInfo,
)
Expand Down Expand Up @@ -943,35 +942,7 @@ async def _force_disconnect(self) -> None:
new_server_info.nonce
).decode()

await connection.write(encode_connect(connect_info))
await connection.write(encode_ping())

try:
while True:
response = await asyncio.wait_for(
parse(connection), timeout=self._reconnect_timeout
)
if not isinstance(response, Ok):
break
except TimeoutError:
await connection.close()
msg = "Server did not respond to PING"
raise ConnectionError(msg)

if response is None:
await connection.close()
msg = "Connection closed before PONG received"
raise ConnectionError(msg)

if isinstance(response, Err):
await connection.close()
msg = f"Connection error: {response.error}"
raise ConnectionError(msg)

if not isinstance(response, Pong):
await connection.close()
msg = f"Unexpected response to PING: {type(response).__name__}"
raise ConnectionError(msg)
await complete_handshake(connection, connect_info, timeout=self._reconnect_timeout)

self._connection = connection
self._server_info = new_server_info
Expand Down Expand Up @@ -1862,37 +1833,7 @@ async def connect(
if server_info.nonce and nkey_signature_handler is not None:
connect_info["sig"] = nkey_signature_handler(server_info.nonce).decode()

await connection.write(encode_connect(connect_info))

await connection.write(encode_ping())

try:
while True:
response = await asyncio.wait_for(parse(connection), timeout=timeout)
if not isinstance(response, Ok):
break

if isinstance(response, Err):
await connection.close()
error_msg = response.error

if "authorization" in error_msg.lower():
msg = f"Authorization failed: {error_msg}"
raise ConnectionError(msg)
else:
msg = f"Connection error: {error_msg}"
raise ConnectionError(msg)

except TimeoutError:
await connection.close()
msg = "Server did not respond to PING"
raise ConnectionError(msg)
except ConnectionError:
raise
except Exception as e:
await connection.close()
msg = f"Failed to verify connection: {e}"
raise ConnectionError(msg)
await complete_handshake(connection, connect_info, timeout=timeout)

client = Client(
connection,
Expand Down
65 changes: 64 additions & 1 deletion nats-core/src/nats/client/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
from urllib.parse import urlparse

from nats.client.errors import SecureConnectionRequiredError
from nats.client.protocol.message import Info, parse
from nats.client.protocol.command import encode_connect, encode_ping
from nats.client.protocol.message import Err, Info, Ok, Pong, parse
from nats.client.protocol.types import ConnectInfo
from nats.client.protocol.types import ServerInfo as ProtocolServerInfo

if TYPE_CHECKING:
Expand Down Expand Up @@ -466,3 +468,64 @@ async def establish_connection(
raise ConnectionError(msg) from e

return connection, info, tls_established


async def complete_handshake(
connection: Connection,
connect_info: ConnectInfo,
*,
timeout: float,
) -> None:
"""Send CONNECT and PING, then wait for the PONG that completes the handshake.

Picks up where :func:`establish_connection` leaves off: the caller has
built the CONNECT payload, and this function owns the rest of the
protocol setup. ``+OK`` acknowledgements (verbose mode) are drained
while waiting. On any failure the connection is closed and a
``ConnectionError`` is raised.

Args:
connection: An open connection that has completed the INFO exchange.
connect_info: The CONNECT payload to send.
timeout: Hard timeout applied to each protocol read.

Raises:
ConnectionError: The server rejected the CONNECT (``-ERR``), closed
the connection before PONG, answered with an unexpected frame,
or did not respond within ``timeout``.
"""
await connection.write(encode_connect(connect_info))
await connection.write(encode_ping())

try:
while True:
response = await asyncio.wait_for(parse(connection), timeout=timeout)
if not isinstance(response, Ok):
break
except TimeoutError:
await connection.close()
msg = "Server did not respond to PING"
raise ConnectionError(msg)
except Exception as e:
await connection.close()
msg = f"Failed to verify connection: {e}"
raise ConnectionError(msg) from e

if response is None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both except handlers unconditionally re-raise, so response is always assigned when execution reaches here — this is correct at runtime. However, a static type checker that doesn't track that both handlers always raise (e.g. mypy without control-flow narrowing) may flag response as potentially unbound here.

Initialising before the loop makes the intent explicit and silences that class of warning:

Suggested change
if response is None:
response: Message | None = None
try:
while True:
response = await asyncio.wait_for(parse(connection), timeout=timeout)
if not isinstance(response, Ok):
break
except TimeoutError:
await connection.close()
msg = "Server did not respond to PING"
raise ConnectionError(msg)
except Exception as e:
await connection.close()
msg = f"Failed to verify connection: {e}"
raise ConnectionError(msg) from e
if response is None:

await connection.close()
msg = "Connection closed before PONG received"
raise ConnectionError(msg)

if isinstance(response, Err):
await connection.close()
error_msg = response.error
if "authorization" in error_msg.lower():
msg = f"Authorization failed: {error_msg}"
else:
msg = f"Connection error: {error_msg}"
raise ConnectionError(msg)

if not isinstance(response, Pong):
await connection.close()
msg = f"Unexpected response to PING: {type(response).__name__}"
raise ConnectionError(msg)
71 changes: 71 additions & 0 deletions nats-core/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1713,6 +1713,77 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) ->
await server.wait_closed()


@pytest.mark.asyncio
async def test_connect_raises_when_server_closes_before_pong():
"""A server that closes the socket mid-handshake fails connect() with a clear
error instead of returning a CONNECTED client on a dead connection."""

async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
info = (
b'INFO {"server_id":"test","server_name":"test","version":"2.0.0","proto":1,'
b'"go":"go1.20","host":"127.0.0.1","port":4222,"headers":true,"max_payload":1048576}\r\n'
)
writer.write(info)
await writer.drain()

await reader.readline() # consume CONNECT
await reader.readline() # consume PING

writer.close()
try:
await writer.wait_closed()
except Exception:
pass

server = await asyncio.start_server(handle, "127.0.0.1", 0)
host, port = server.sockets[0].getsockname()[:2]

try:
with pytest.raises(ConnectionError, match="closed before PONG"):
await connect(f"nats://{host}:{port}", timeout=1.0, allow_reconnect=False)
finally:
server.close()
await server.wait_closed()


@pytest.mark.asyncio
async def test_connect_raises_on_unexpected_handshake_response():
"""A frame other than PONG in response to the handshake PING fails connect()."""

async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
info = (
b'INFO {"server_id":"test","server_name":"test","version":"2.0.0","proto":1,'
b'"go":"go1.20","host":"127.0.0.1","port":4222,"headers":true,"max_payload":1048576}\r\n'
)
writer.write(info)
await writer.drain()

await reader.readline() # consume CONNECT
await reader.readline() # consume PING

# Answer with another INFO instead of the expected PONG.
writer.write(info)
await writer.drain()

while await reader.read(4096):
pass
writer.close()
try:
await writer.wait_closed()
except Exception:
pass

server = await asyncio.start_server(handle, "127.0.0.1", 0)
host, port = server.sockets[0].getsockname()[:2]

try:
with pytest.raises(ConnectionError, match="Unexpected response to PING"):
await connect(f"nats://{host}:{port}", timeout=1.0, allow_reconnect=False)
finally:
server.close()
await server.wait_closed()


@pytest.mark.parametrize(
"kwargs, drop_first, expected_captures, expected",
[
Expand Down
Loading