Skip to content

Commit 6084dfd

Browse files
Report post-redemption connect failures instead of raising tracebacks
Both paths fail *after* the control plane has consumed the single-use connect token, so a raw traceback left the customer unable to tell whether to retry or fetch a new token from the portal. `http.client.IncompleteRead` (a truncated chunked reply) subclasses `HTTPException`/`ValueError` -- neither `OSError` nor `URLError` -- so it escaped both of `post_connect`'s handlers. A third clause is added *after* the transport clause, because `RemoteDisconnected` is both a `ConnectionResetError` and a `BadStatusLine` and means the peer never answered: that one keeps the retryable 503 copy, and a test pins the ordering. `save_bootstrap` re-runs `validate_cloud_base_url` on both endpoints, which resolves the host. A resolver that dies mid-connect raises `CloudUrlUnresolved` and an endpoint that starts resolving to a rejected address raises a bare `ValueError`; both are `ValueError`, so neither the `CloudSessionError` nor the `OSError` handler in `connect()` covered them. Translated in `connect()` rather than by dropping the re-validation, which is still wanted for `save_bootstrap`'s other callers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 9d77f4f commit 6084dfd

3 files changed

Lines changed: 173 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ All notable changes to Engraphis are documented here. Format loosely follows
1919
with a lapsed subscription). Session storage is pre-flighted before the exchange, so an
2020
unwritable state directory or a `cloud_session.json` replaced by a link fails the command
2121
*without* spending the single-use token — the customer fixes the path and retries with the
22-
same token instead of returning to the portal for a new one. Also installed as
22+
same token instead of returning to the portal for a new one. Faults that can only happen
23+
*after* the exchange — a reply truncated mid-body (`http.client.IncompleteRead`), or an
24+
endpoint that stops resolving before the session is written (`CloudUrlUnresolved`) — are
25+
reported as errors that say the token was already used, rather than escaping as tracebacks
26+
that leave the customer unable to tell whether to retry. Also installed as
2327
`engraphis-connect`.
2428
- An `engraphis` front-door command that dispatches to the existing `engraphis-<verb>`
2529
entry points, so the command the account portal displays is runnable as shown.

engraphis/device_connect.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"""
2626
from __future__ import annotations
2727

28+
import http.client
2829
import json
2930
import math
3031
import os
@@ -65,6 +66,17 @@
6566
#: The control plane answers with a small fixed record; anything larger is not ours.
6667
_MAX_RESPONSE_BYTES = 64 * 1024
6768

69+
#: Copy for a reply that began but never completed. Every other failure in this module can
70+
#: promise the connect token is untouched; this one cannot, because the request reached a
71+
#: control plane that started to answer, and a 200 consumes the token as it is written.
72+
#: Saying "try again" here would be a lie that costs the customer a second failed attempt,
73+
#: so the copy sends them to the one place that shows whether the device landed.
74+
_TRUNCATED_REPLY = (
75+
"Engraphis Cloud started answering this connect request but the connection closed "
76+
"before the reply was complete. Your connect token may already have been used: check "
77+
"your account portal, and generate a new one if this device is not listed there."
78+
)
79+
6880
#: The portal always mints tokens with this prefix. Checking it locally turns a
6981
#: mistyped or truncated paste into an instant, free error instead of a round trip that
7082
#: consumes rate budget and returns the same opaque 401 as a genuinely dead token.
@@ -457,11 +469,25 @@ def post_connect(control_url: str, token: str, *, installation_client_id: str,
457469
raise _connect_http_error(status)
458470
except (urllib.error.URLError, TimeoutError, OSError) as exc:
459471
# ``exc`` may quote an internal host or a proxy URL; never reflect it.
472+
#
473+
# Deliberately ahead of the ``HTTPException`` clause: ``RemoteDisconnected`` is
474+
# both a ``ConnectionResetError`` and a ``BadStatusLine``, and it means the peer
475+
# closed without answering at all. Nothing was consumed, so "try again" is the
476+
# correct advice for it and it must not inherit the token-may-be-spent copy.
460477
raise DeviceConnectError(
461478
"Engraphis Cloud is temporarily unreachable. Check your network and try "
462479
"again.",
463480
status=503,
464481
) from exc
482+
except http.client.HTTPException as exc:
483+
# A truncated chunked body makes ``HTTPResponse.read`` raise ``IncompleteRead``,
484+
# which subclasses ``HTTPException``/``ValueError`` and is neither an ``OSError``
485+
# nor a ``URLError`` -- so it escaped both clauses above as a raw traceback. That
486+
# happened at the worst moment: the control plane had already answered, so the
487+
# single-use token was very likely spent, and the customer saw a stack trace
488+
# instead of being told to check the portal. ``LineTooLong``/``BadStatusLine`` from
489+
# reading the status line and headers land here for the same reason.
490+
raise DeviceConnectError(_TRUNCATED_REPLY, status=502) from exc
465491
if len(raw) > _MAX_RESPONSE_BYTES:
466492
raise DeviceConnectError(
467493
"Engraphis Cloud returned an oversized connect response.", status=502
@@ -603,6 +629,20 @@ def connect(token: object, *, control_url: Optional[str] = None,
603629
"portal -- this one has been used." % session_path,
604630
status=409,
605631
) from exc
632+
except ValueError as exc:
633+
# ``save_bootstrap`` re-runs ``validate_cloud_base_url`` on both endpoints, and
634+
# that helper *resolves* the host. A resolver that dies between the pre-POST check
635+
# and this line raises ``CloudUrlUnresolved``; an endpoint that starts resolving to
636+
# a rejected address raises a bare ``ValueError``. Both are ``ValueError``, so
637+
# neither the ``CloudSessionError`` nor the ``OSError`` clause above covered them
638+
# and they escaped as a traceback -- again after the token was already spent.
639+
raise DeviceConnectError(
640+
"Engraphis Cloud accepted the token, but its endpoints could not be verified "
641+
"in time to save the session, so nothing was written. Check your network, "
642+
"then connect again with a new token from your account portal -- this one "
643+
"has been used.",
644+
status=409,
645+
) from exc
606646

607647
summary = summarize(response)
608648
summary["control_url"] = resolved_control

tests/test_device_connect.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88
from __future__ import annotations
99

10+
import http.client
1011
import json
1112
import os
1213
import urllib.error
@@ -17,6 +18,7 @@
1718
import pytest
1819

1920
from engraphis import cloud_session, device_connect
21+
from engraphis.hosted_client import CloudUrlUnresolved
2022
from engraphis.private_state import UnsafeStateFile
2123
from scripts import connect as connect_cli
2224

@@ -350,6 +352,132 @@ def open(self, request, timeout):
350352
device_connect.connect(TOKEN, control_url=CONTROL_URL, compute_url=COMPUTE_URL)
351353

352354

355+
# ------------------------------------------- failures *after* the token has been spent
356+
#
357+
# Once ``opener.open`` has returned, the control plane has answered and the single-use
358+
# connect token is gone. Every fault from that point on has to arrive as a
359+
# ``DeviceConnectError`` that says so -- a traceback here is the worst possible outcome,
360+
# because the customer cannot tell whether to retry or fetch a new token.
361+
362+
363+
class _TruncatedBody:
364+
"""A 200 whose body stops mid-stream, the way a dropped chunked reply does."""
365+
366+
def __enter__(self):
367+
return self
368+
369+
def __exit__(self, *exc) -> bool:
370+
return False
371+
372+
def read(self, size: int = -1) -> bytes:
373+
# What ``HTTPResponse._read_chunked`` raises for a truncated body. It subclasses
374+
# ``HTTPException``/``ValueError`` -- neither ``OSError`` nor ``URLError``.
375+
raise http.client.IncompleteRead(b'{"organization_id":"org_al')
376+
377+
378+
@pytest.mark.parametrize("failure", [
379+
http.client.IncompleteRead(b'{"organization_id":"org_al'),
380+
http.client.LineTooLong("header line"),
381+
http.client.BadStatusLine("garbage"),
382+
])
383+
def test_a_truncated_reply_reports_the_token_state_not_a_traceback(
384+
monkeypatch, tmp_path, failure
385+
):
386+
"""``IncompleteRead`` is an ``HTTPException``, so the transport clause never saw it."""
387+
388+
class _Broken(_Opener):
389+
def open(self, request, timeout):
390+
self.calls.append({"body": request.data})
391+
if isinstance(failure, http.client.IncompleteRead):
392+
return _TruncatedBody()
393+
raise failure
394+
395+
opener = _Broken()
396+
_install_opener(monkeypatch, opener)
397+
398+
with pytest.raises(device_connect.DeviceConnectError) as caught:
399+
device_connect.connect(TOKEN, control_url=CONTROL_URL, compute_url=COMPUTE_URL)
400+
401+
message = str(caught.value)
402+
assert caught.value.status == 502
403+
# The customer must be told the token may be gone rather than told to just retry.
404+
assert "may already have been used" in message
405+
assert TOKEN not in message
406+
assert not (tmp_path / "cloud_session.json").exists()
407+
408+
409+
def test_a_connection_reset_before_any_reply_still_says_retry(monkeypatch):
410+
"""``RemoteDisconnected`` is *both* an ``OSError`` and an ``HTTPException``.
411+
412+
Nothing was read, so the token is untouched and "try again" remains the right copy;
413+
it must not be swept into the token-may-be-spent message by the new clause.
414+
"""
415+
416+
_install_opener(
417+
monkeypatch, _Opener(error=http.client.RemoteDisconnected("closed early"))
418+
)
419+
420+
with pytest.raises(device_connect.DeviceConnectError) as caught:
421+
device_connect.connect(TOKEN, control_url=CONTROL_URL, compute_url=COMPUTE_URL)
422+
423+
assert caught.value.status == 503
424+
assert "temporarily unreachable" in str(caught.value)
425+
426+
427+
@pytest.mark.parametrize("failure", [
428+
CloudUrlUnresolved("cloud service URL could not be resolved"),
429+
ValueError("cloud service URL must not target private/reserved IP ranges"),
430+
])
431+
def test_endpoint_validation_failing_after_redemption_is_not_a_traceback(
432+
monkeypatch, tmp_path, failure
433+
):
434+
"""``save_bootstrap`` re-resolves both endpoints *after* the POST spent the token.
435+
436+
``CloudUrlUnresolved`` is a ``ValueError``, so neither the ``CloudSessionError`` nor
437+
the ``OSError`` handler in ``connect()`` covers it: a resolver that dies mid-connect,
438+
or a host that starts resolving to a rejected address, escaped as a raw traceback.
439+
"""
440+
441+
opener = _Opener(body=REGISTRATION)
442+
_install_opener(monkeypatch, opener)
443+
444+
def _rejects(value):
445+
raise failure
446+
447+
# Only the save path: the pre-POST checks in ``connect()`` already passed.
448+
monkeypatch.setattr(cloud_session, "validate_cloud_base_url", _rejects)
449+
450+
with pytest.raises(device_connect.DeviceConnectError) as caught:
451+
device_connect.connect(TOKEN, control_url=CONTROL_URL, compute_url=COMPUTE_URL)
452+
453+
message = str(caught.value)
454+
assert caught.value.status == 409
455+
assert opener.calls, "the token was spent; the copy has to admit it"
456+
assert "has been used" in message
457+
assert TOKEN not in message
458+
assert not (tmp_path / "cloud_session.json").exists()
459+
460+
461+
def test_the_cli_reports_a_post_redemption_fault_instead_of_a_traceback(
462+
monkeypatch, capsys
463+
):
464+
_install_opener(monkeypatch, _Opener(body=REGISTRATION))
465+
466+
def _rejects(value):
467+
raise CloudUrlUnresolved("cloud service URL could not be resolved")
468+
469+
monkeypatch.setattr(cloud_session, "validate_cloud_base_url", _rejects)
470+
471+
code = connect_cli.main([
472+
"--token", TOKEN, "--control-url", CONTROL_URL, "--compute-url", COMPUTE_URL,
473+
])
474+
475+
err = capsys.readouterr().err
476+
assert code == 1
477+
assert "Traceback" not in err
478+
assert "has been used" in err
479+
480+
353481
# ------------------------------------------------------ pre-flight on session storage
354482
#
355483
# The POST is the point of no return: the control plane consumes the single-use connect

0 commit comments

Comments
 (0)