"""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 meshbay_node.config import Config, 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") def test_a_node_waiting_for_its_link_stays_under_the_hubs_sign_in_limit(): """At 5s a node waiting for its key made 12 attempts a minute against a limit of 10: it put itself in 429 back-off within a minute, every minute, and the desktop app did not recognise that state as a node at all.""" import re from pathlib import Path from meshbay_node.daemon import NodeDaemon nodes_py = (Path(__file__).resolve().parents[2] / "meshbay-hub" / "src" / "meshbay_hub" / "api" / "nodes.py").read_text(encoding="utf-8") m = re.search(r'@router\.post\("/auth"\)\s*@limiter\.limit\("(\d+)/minute"\)', nodes_py) assert m, "the hub's node sign-in limit moved; update this test" per_minute = int(m.group(1)) assert 60 / NodeDaemon.LINK_WAIT_S < per_minute @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)