Skip to content

Validate handshake completion on initial connect - #975

Draft
caspervonb wants to merge 2 commits into
nats-core-connect-verbose-pedantic-protocol-overridesfrom
nats-core-handshake-completion
Draft

Validate handshake completion on initial connect#975
caspervonb wants to merge 2 commits into
nats-core-connect-verbose-pedantic-protocol-overridesfrom
nats-core-handshake-completion

Conversation

@caspervonb

Copy link
Copy Markdown
Collaborator

Initial connect only checked for -ERR after sending CONNECT+PING; a server that closed the socket mid-handshake or answered with an unexpected frame fell through silently and connect() returned a CONNECTED client wrapping a dead connection. The reconnect path already validated all three outcomes — this closes the asymmetry flagged in review on #949 by extracting the shared sequence into complete_handshake alongside establish_connection (#948), so the two paths cannot drift again.

Stacked on #949; will retarget to main once that merges.

The initial-connect path only checked for -ERR after CONNECT+PING; a
server that closed the socket or answered with an unexpected frame
fell through silently and produced a CONNECTED client on a dead
connection. The reconnect path already validated all three outcomes.

Move the shared CONNECT+PING+PONG sequence into complete_handshake
next to establish_connection so both paths get the same checks, and
both now special-case authorization failures in the error message.
@synadia-claude-reviewer

synadia-claude-reviewer Bot commented Jun 10, 2026

Copy link
Copy Markdown

Claude finished @caspervonb's task in 3m 35s —— View job


PR Review

  • Gather context (PR diff, changed files)
  • Review connection.pycomplete_handshake implementation
  • Review __init__.py — initial connect path
  • Review test_client.py — new test coverage
  • Post inline comments and summary

The extraction is clean and the core goal is achieved: initial connect now validates None (EOF mid-handshake) and unexpected frames, closing the gap that was left in the old path. A few things worth knowing:

response potentially unbound (inline comment) — Both except handlers always re-raise, so response is provably assigned at line 514 at runtime. But a type checker that doesn't track "all paths raise" (mypy without narrowing) will flag it as potentially unbound. Initialising response: Message | None = None before the try block costs nothing and future-proofs against a type-checking pass being added to nats-core.

Reconnect now gets auth-aware error messages — The old reconnect path raised ConnectionError("Connection error: ...") for every -ERR. The new shared path checks for "authorization" in the error text and raises ConnectionError("Authorization failed: ...") accordingly. This is a positive improvement, but it's a subtle behaviour change for any caller matching on reconnect error strings. Worth calling out in the PR description if it isn't already clear.

ConnectionError from parse() is now wrapped — The original initial-connect try block had except ConnectionError: raise to pass through errors from the underlying transport unchanged. complete_handshake drops that guard; a ConnectionError raised inside parse() (e.g. from a bad readline call) will now be re-wrapped as ConnectionError("Failed to verify connection: ..."). In practice the message is preserved via from e, so debuggability is fine — just worth knowing the contract changed slightly.

Tests — The two new tests cover exactly the previously-missing scenarios. @pytest.mark.asyncio is redundant with asyncio_mode = "auto" but consistent with the existing test style and harmless.

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:

Static checkers that do not track that both except handlers always
raise could flag response as possibly unbound at the post-loop
checks. Start it as None so the existing None guard also covers the
declaration.
@caspervonb

Copy link
Copy Markdown
Collaborator Author

Review comments addressed, PTAL @claude

@synadia-claude-reviewer

synadia-claude-reviewer Bot commented Jul 13, 2026

Copy link
Copy Markdown

Claude finished @caspervonb's task in 5m 6s —— View job


Re-review

  • Gather context
  • Review current diff for addressed comments
  • Check connection.pyresponse initialization
  • Check all three changed files for any remaining issues
  • Post final review

The response initialization is correctly applied. No remaining issues — this is ready.

response: Message | None = None (66ceadd) — Fixed as suggested. Importantly, this isn't just a type-checker appeasement: parse() is typed -> Message | None and explicitly returns None on EOF (message.py:347–348):

control_line = await reader.readline()
if not control_line:
    return None

So when the server closes the socket, parse() returns None, the loop breaks, and if response is None: handles it correctly — which is exactly what test_connect_raises_when_server_closes_before_pong exercises. The None initialization makes the type declaration accurate, not just permissive.

The TYPE_CHECKING import is correctresponse: Message | None = None is a local variable annotation inside a function body; Python does not evaluate those at runtime, so Message being import-guarded under TYPE_CHECKING is safe.

except Exception now wraps all errors uniformly — the original initial-connect path had except ConnectionError: raise to pass transport errors through unchanged; complete_handshake drops that and wraps everything. The error message is preserved via from e and the raised type is still ConnectionError, so this is a fine tradeoff for consistency between the two call sites. The reconnect path never had the guard either.

Tests — both test_connect_raises_when_server_closes_before_pong and test_connect_raises_on_unexpected_handshake_response are correct. The mock server patterns (consume CONNECT+PING, then either close or reply with INFO) precisely target the previously-missing paths. @pytest.mark.asyncio is redundant with asyncio_mode = "auto" but harmless and consistent with the existing test style.

LGTM.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant