From 39a2f70c3bf404774212ab040a0c88e0470bd3b6 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Mon, 2 Mar 2026 21:53:12 +0000 Subject: [PATCH] Fix sunbeam check: group by namespace, never crash on network errors Output now mirrors sunbeam status (namespace headers, checks indented below). Any uncaught exception from a check is caught in cmd_check and displayed as a failed check instead of crashing. Also fix _http_get: TimeoutError and other raw OSError/SSL errors that Python 3.13 doesn't always wrap in URLError are now normalized to URLError before re-raising, so each check function's URLError handler reliably catches all network failures. --- sunbeam/checks.py | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/sunbeam/checks.py b/sunbeam/checks.py index dca3897..672874f 100644 --- a/sunbeam/checks.py +++ b/sunbeam/checks.py @@ -62,13 +62,23 @@ def _opener(ssl_ctx: ssl.SSLContext) -> urllib.request.OpenerDirector: def _http_get(url: str, opener: urllib.request.OpenerDirector, *, headers: dict | None = None, timeout: int = 10) -> tuple[int, bytes]: - """Return (status_code, body). Redirects are not followed.""" + """Return (status_code, body). Redirects are not followed. + + Any network/SSL error (including TimeoutError) is re-raised as URLError + so callers only need to catch urllib.error.URLError. + """ req = urllib.request.Request(url, headers=headers or {}) try: with opener.open(req, timeout=timeout) as resp: return resp.status, resp.read() except urllib.error.HTTPError as e: return e.code, b"" + except urllib.error.URLError: + raise + except OSError as e: + # TimeoutError and other socket/SSL errors don't always get wrapped + # in URLError by Python's urllib — normalize them here. + raise urllib.error.URLError(e) from e # ── Individual checks ───────────────────────────────────────────────────────── @@ -243,23 +253,35 @@ def cmd_check(target: str | None) -> None: op = _opener(ssl_ctx) ns_filter, svc_filter = parse_target(target) if target else (None, None) - fns = [ - fn for fn, ns, svc in CHECKS + selected = [ + (fn, ns, svc) for fn, ns, svc in CHECKS if (ns_filter is None or ns == ns_filter) and (svc_filter is None or svc == svc_filter) ] - if not fns: + if not selected: warn(f"No checks match target: {target}") return + # Run all checks; catch any unexpected exception so we never crash. results = [] - for fn in fns: - r = fn(domain, op) + for fn, ns, svc in selected: + try: + r = fn(domain, op) + except Exception as e: + r = CheckResult(fn.__name__.replace("check_", ""), ns, svc, False, str(e)[:80]) results.append(r) + + # Print grouped by namespace (mirrors sunbeam status layout). + name_w = max(len(r.name) for r in results) + cur_ns = None + for r in results: + if r.ns != cur_ns: + print(f" {r.ns}:") + cur_ns = r.ns icon = "\u2713" if r.passed else "\u2717" - detail = f" ({r.detail})" if r.detail else "" - print(f" {icon} {r.ns}/{r.svc} [{r.name}]{detail}") + detail = f" {r.detail}" if r.detail else "" + print(f" {icon} {r.name:<{name_w}}{detail}") print() failed = [r for r in results if not r.passed]