diff options
3 files changed, 135 insertions, 5 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js index f81247d..85fe53c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js @@ -142,11 +142,15 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru const linkNodeKey = useCallback(async (pk) => { if (!pk) return; - try { - await hubFetch('/v1/users/me/node_key', { - method: 'PUT', token, body: { pk_node_ed25519: pk }, - }); - } catch { /* already linked or same key */ } + // `PUT /me/node_key` is idempotent — linking the same key again returns 200, + // so there is no "already linked" case to swallow here. A failure means the + // hub did not record this node's key (a rejected session, a malformed key), + // and the node then fails to authenticate and never comes up. It must + // surface — `detectNode`'s catch shows it — rather than let the wizard + // proceed against a node that looks linked but is not. + await hubFetch('/v1/users/me/node_key', { + method: 'PUT', token, body: { pk_node_ed25519: pk }, + }); }, [token]); const detectNode = useCallback(async () => { diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 4800dac..dc5df81 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -983,6 +983,23 @@ class NodeDaemon(EnrichmentMixin): self._config.hub.username, self._config.hub.url, ) await asyncio.sleep(5) + elif e.response.status_code in (429, 500, 502, 503, 504): + # Transient: the hub is busy (429 — often this daemon's own + # retry storm against the sign-in rate limit), restarting + # (502/503) or erroring (500/504). None of these is a reason + # to exit: the daemon exiting here crash-loops under systemd + # and strands the operator, who needs it alive to read the + # node key (`meshbay-node status`, the desktop client) so + # they can link it. Back off — respecting Retry-After when + # the hub sends one — and try again, rather than dying. + self._state["status"] = "waiting_for_hub" + delay = 10 + ra = (e.response.headers or {}).get("Retry-After") + if ra and str(ra).isdigit(): + delay = min(max(delay, int(ra)), 300) + log.warning("Hub returned %s on login — retrying in %ds", + e.response.status_code, delay) + await asyncio.sleep(delay) else: raise except Exception as e: diff --git a/packages/meshbay-node/tests/test_login_retry_is_resilient.py b/packages/meshbay-node/tests/test_login_retry_is_resilient.py new file mode 100644 index 0000000..e37a413 --- /dev/null +++ b/packages/meshbay-node/tests/test_login_retry_is_resilient.py @@ -0,0 +1,109 @@ +"""A transient hub state on node sign-in must not crash the daemon. + +`_login_with_retry` retries a 401 (the node key is not linked yet — a human has +to link it, and the daemon must stay alive so its key can be read). It used to +`raise` on every other status, so a **429** (the daemon's own retries hitting +the sign-in rate limit) or a **502/503** (the hub restarting during a deploy) +killed the process — systemd then crash-looped it, which is what "impossible de +démarrer le node" looked like after a reset left the node with a fresh, unlinked +key. Those transient statuses are now retried with a back-off that respects +`Retry-After`. +""" + +import asyncio + +import httpx +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_node.config import Config, GroupConfig, HubConfig, KeystoreConfig, NodeConfig +from meshbay_node.daemon import NodeDaemon + + +def _daemon(tmp_path): + cfg = Config( + hub=HubConfig(url="http://localhost:9999", username="testuser"), + node=NodeConfig(quic_port=29011, ui_port=29012), + groups=[], + keystore=KeystoreConfig(path=tmp_path / "keystore.enc"), + data_dir=tmp_path / "data", + ) + return NodeDaemon(cfg) + + +def _http_error(status: int, headers: dict | None = None) -> httpx.HTTPStatusError: + req = httpx.Request("POST", "http://localhost:9999/v1/nodes/auth") + resp = httpx.Response(status, headers=headers or {}, request=req) + return httpx.HTTPStatusError(f"{status}", request=req, response=resp) + + +class _Hub: + """A hub whose `startup` raises the given sequence, then returns a session.""" + def __init__(self, seq): + self._seq = list(seq) + self.calls = 0 + + async def startup(self, endpoint_hint=None): + self.calls += 1 + item = self._seq.pop(0) + if isinstance(item, Exception): + raise item + return item + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [429, 500, 502, 503, 504]) +async def test_a_transient_status_is_retried_not_fatal(tmp_path, status, monkeypatch): + slept = [] + + async def _sleep(d): + slept.append(d) + monkeypatch.setattr(asyncio, "sleep", _sleep) + + daemon = _daemon(tmp_path) + session = object() + hub = _Hub([_http_error(status), session]) # transient, then success + got = await daemon._login_with_retry(hub) + assert got is session # it recovered instead of crashing + assert hub.calls == 2 # retried once + assert slept # it backed off + + +@pytest.mark.asyncio +async def test_retry_after_is_respected(tmp_path, monkeypatch): + slept = [] + + async def _sleep(d): + slept.append(d) + monkeypatch.setattr(asyncio, "sleep", _sleep) + + daemon = _daemon(tmp_path) + hub = _Hub([_http_error(429, {"Retry-After": "42"}), object()]) + await daemon._login_with_retry(hub) + assert 42 in slept + + +@pytest.mark.asyncio +async def test_a_401_still_retries_and_stays_alive(tmp_path, monkeypatch): + async def _sleep(d): + pass + monkeypatch.setattr(asyncio, "sleep", _sleep) + + daemon = _daemon(tmp_path) + session = object() + hub = _Hub([_http_error(401), session]) + got = await daemon._login_with_retry(hub) + assert got is session + assert daemon._state.get("status") in ("waiting_for_node_key", "waiting_for_account") + + +@pytest.mark.asyncio +async def test_a_genuine_client_error_still_raises(tmp_path, monkeypatch): + """A 400/422 is a bug, not a transient state — it must not be swallowed.""" + async def _sleep(d): + pass + monkeypatch.setattr(asyncio, "sleep", _sleep) + + daemon = _daemon(tmp_path) + hub = _Hub([_http_error(400), object()]) + with pytest.raises(httpx.HTTPStatusError): + await daemon._login_with_retry(hub) |