From d295fd4250a7cb0ba6a2b79ab84eb71b64315a17 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 00:02:34 +0200 Subject: [PATCH] Say nothing serves a call only for the bus's own no-responders, never for a tool's error that mentions 503 (issue 1529) --- node-tools/internal/bus/bus.go | 6 +++- node-tools/internal/console/client.go | 6 ++-- .../internal/console/noresponders_test.go | 28 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 node-tools/internal/console/noresponders_test.go diff --git a/node-tools/internal/bus/bus.go b/node-tools/internal/bus/bus.go index 84b3aa6..c864de3 100644 --- a/node-tools/internal/bus/bus.go +++ b/node-tools/internal/bus/bus.go @@ -708,12 +708,16 @@ var HandoverPause = 3 * time.Second // (mesh-controller internal/link, `"retry": "handing-over"`). const RetryHandingOver = "handing-over" +// ErrNoResponders is the bus's own refusal: nothing subscribes to the subject asked. It is told apart +// from a tool's own error by errors.Is, never by its text (a tool may say "503" itself, issue 1529). +var ErrNoResponders = errors.New("503 no responders") + // askOnce asks once, and says whether the answer was a refusal marked to be asked again. func (c *Conn) askOnce(subject string, data []byte) (Answered, bool, error) { msg, err := c.nc.Request(subject, data, RequestTimeout) if err != nil { if errors.Is(err, nats.ErrNoResponders) { - return Answered{}, false, errors.New("503 no responders") + return Answered{}, false, ErrNoResponders } if errors.Is(err, nats.ErrTimeout) { return Answered{}, false, errors.New("timeout") diff --git a/node-tools/internal/console/client.go b/node-tools/internal/console/client.go index 1b80b87..41fdeb5 100644 --- a/node-tools/internal/console/client.go +++ b/node-tools/internal/console/client.go @@ -7,6 +7,8 @@ import ( "regexp" "strings" + "github.com/nats-io/nats.go" + "github.com/novox/mesh-tools/node-tools/internal/bus" ) @@ -109,7 +111,6 @@ func subjectListed(name, node string, l *Listing) string { } var ( - noResponders = regexp.MustCompile(`(?i)no responders|503`) // The bus's own two refusals, by their whole phrase. "authorization" alone also matched a tool's own // answer that merely mentions the word — the runtime refusing a state value with an Authorization // header (novox/hq ADR 0201) read as "this account may not call", which sent the reader the wrong way. @@ -127,7 +128,8 @@ func whyItFailed(key string, err error) string { } msg := err.Error() switch { - case noResponders.MatchString(msg): + // By type, not text: a tool's own error that mentions 503 is the tool's answer (issue 1529). + case errors.Is(err, bus.ErrNoResponders) || errors.Is(err, nats.ErrNoResponders): extra := "" if strings.HasPrefix(key, "seat:") { extra = ", or nothing holds that seat" diff --git a/node-tools/internal/console/noresponders_test.go b/node-tools/internal/console/noresponders_test.go new file mode 100644 index 0000000..fd008c8 --- /dev/null +++ b/node-tools/internal/console/noresponders_test.go @@ -0,0 +1,28 @@ +package console + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/nats-io/nats.go" + + "github.com/novox/mesh-tools/node-tools/internal/bus" +) + +// A tool's own error that mentions 503 is the tool's answer, not a routing fault (issue 1529). +func TestAToolsOwn503IsNotNothingServes(t *testing.T) { + got := whyItFailed("scanner.scan", errors.New("the scanner refused the scan job: 503 Service Unavailable")) + if strings.Contains(got, "nothing serves") || !strings.HasPrefix(got, "scanner.scan failed: ") || !strings.Contains(got, "503") { + t.Fatalf("got %q", got) + } +} + +func TestARealNoRespondersStillSaysNothingServes(t *testing.T) { + for _, err := range []error{bus.ErrNoResponders, fmt.Errorf("ask: %w", bus.ErrNoResponders), nats.ErrNoResponders} { + if got := whyItFailed("a.b", err); !strings.HasPrefix(got, "nothing serves a.b") { + t.Fatalf("%v: got %q", err, got) + } + } +}