From ec3b684e6e55564ac8f5727b57878f89f5f335be Mon Sep 17 00:00:00 2001 From: flemming-it Date: Wed, 15 Jul 2026 04:45:16 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20probe()=20=E2=80=94=20health=20check=20?= =?UTF-8?q?that=20separates=20auth=20rejection=20from=20unreachable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit healthy() collapses every failure to false, so a hub that is up but rejects the bearer token was indistinguishable from a dead endpoint. probe() returns serving / notServing / authRejected (UNAUTHENTICATED or PERMISSION_DENIED) / unreachable so client UIs can point the operator at the token instead of the wire. healthy() is unchanged. Signed-off-by: flemming-it --- lib/src/hub_client.dart | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/lib/src/hub_client.dart b/lib/src/hub_client.dart index ae2f0ba..aa3451f 100644 --- a/lib/src/hub_client.dart +++ b/lib/src/hub_client.dart @@ -43,6 +43,26 @@ class HubEndpoint { String toString() => '${secure ? "https" : "http"}://$host:$port'; } +/// Outcome of a [HubClient.probe] health check. Distinguishes +/// "the hub is down" from "the hub is up but rejected our +/// credentials" — the two need different operator guidance. +enum HubProbeResult { + /// Hub responded with SERVING. + serving, + + /// Hub responded, but not with SERVING (starting up or + /// shutting down). + notServing, + + /// Hub is reachable but rejected the call as UNAUTHENTICATED + /// or PERMISSION_DENIED — the endpoint is fine, the token + /// is missing, wrong, or lacks scope. + authRejected, + + /// Connection-level failure (refused, timeout, TLS, DNS). + unreachable, +} + /// Re-exports of generated protobuf types so callers don't have /// to import the `generated/` directory directly. typedef CapabilityEntry = pb.CapabilityEntry; @@ -150,6 +170,26 @@ class HubClient { } } + /// Like [healthy], but keeps enough of the failure to tell an + /// auth rejection apart from a dead endpoint, so UIs can say + /// "check your token" instead of a misleading "unreachable". + Future probe() async { + try { + final r = await _hub.health(Empty()); + return r.state == pb.HealthStatus_State.SERVING + ? HubProbeResult.serving + : HubProbeResult.notServing; + } on grpc.GrpcError catch (e) { + if (e.code == grpc.StatusCode.unauthenticated || + e.code == grpc.StatusCode.permissionDenied) { + return HubProbeResult.authRejected; + } + return HubProbeResult.unreachable; + } catch (_) { + return HubProbeResult.unreachable; + } + } + /// All capabilities provided by installed modules. Future> listCapabilities() async { final r = await _admin.listCapabilities(Empty());