[client] Classify the daemon's refusals in the GUI (review item 3)

FailedPrecondition reached the classifier unmatched, so a refusal showed as
"Operation failed". It is the code both of the daemon's deliberate refusals
carry: the update-settings kill switch, and a field an MDM policy manages.

Both are now named — settings_locked and settings_managed_by_mdm, matched on
the message the daemon composes — and FailedPrecondition itself falls back to
change_refused, so a refusal the daemon grows later still reads as a refusal
rather than a failure.

Only the English strings are added. Bundle.Translate falls back to the
default language for a missing key, so other locales show English until the
usual translation pass, rather than the bare "error.<code>" the classifier
would otherwise surface.

Note: the package needs GTK4/WebKit to build, which this machine has not, so
the test is type-checked (go vet, GOOS=windows) but was not executed locally;
CI's Linux job runs it.
This commit is contained in:
riccardom
2026-09-11 16:16:17 +02:00
parent 36bbef21cd
commit 55791332f3
3 changed files with 43 additions and 0 deletions
+12
View File
@@ -1795,6 +1795,18 @@
"message": "The NetBird daemon is not responding. Please check that the service is running.",
"description": "Error: the NetBird background service isn't responding. 'daemon' = the background service."
},
"error.settings_locked": {
"message": "Settings cannot be changed on this device: an administrator has locked them.",
"description": "Error: the local daemon was started with update-settings disabled, so it refuses configuration changes."
},
"error.settings_managed_by_mdm": {
"message": "This setting is managed by your organization and cannot be changed.",
"description": "Error: the setting is enforced by an MDM policy. 'MDM' = mobile device management, the organization's device-management system."
},
"error.change_refused": {
"message": "The NetBird service refused this change.",
"description": "Error: the daemon answered the request and declined it. Generic fallback for a refusal with no more specific cause."
},
"error.unknown": {
"message": "Operation failed.",
"description": "Generic fallback error message used when no specific error applies."
+9
View File
@@ -134,6 +134,10 @@ func (c errorClassifier) classify(err error) *ClientError {
strings.Contains(lower, "connection refused"),
strings.Contains(lower, "context deadline exceeded"):
code = "daemon_unreachable"
case strings.Contains(lower, "update settings are disabled"):
code = "settings_locked"
case strings.Contains(lower, "managed by mdm"):
code = "settings_managed_by_mdm"
}
// Fall back to the gRPC status code when the message didn't match a known
@@ -145,6 +149,11 @@ func (c errorClassifier) classify(err error) *ClientError {
code = "permission_denied"
case gcodes.Unavailable, gcodes.DeadlineExceeded:
code = "daemon_unreachable"
case gcodes.FailedPrecondition:
// The daemon answered and refused. The two refusals it composes
// are matched above; anything else that reaches here is still a
// refusal, so say that rather than "operation failed".
code = "change_refused"
}
}
+22
View File
@@ -34,6 +34,28 @@ func TestErrorClassifier_Classify(t *testing.T) {
require.Equal(t, "session_expired", ce.Code)
})
t.Run("the update-settings kill switch is a refusal, not a failure", func(t *testing.T) {
err := gstatus.Error(gcodes.FailedPrecondition,
"update settings are disabled, you cannot use this feature without update settings enabled")
ce := c.classify(err)
require.NotNil(t, ce)
require.Equal(t, "settings_locked", ce.Code)
})
t.Run("an MDM-managed field is named as such", func(t *testing.T) {
err := gstatus.Error(gcodes.FailedPrecondition,
"fields managed by MDM cannot be modified: [managementURL]")
require.Equal(t, "settings_managed_by_mdm", c.classify(err).Code)
})
t.Run("any other refusal is still a refusal", func(t *testing.T) {
// FailedPrecondition means the daemon answered and declined; falling
// through to "unknown" showed "Operation failed" instead.
require.Equal(t, "change_refused", c.classify(gstatus.Error(gcodes.FailedPrecondition, "something else")).Code)
})
t.Run("unavailable code maps to daemon_unreachable", func(t *testing.T) {
ce := c.classify(gstatus.Error(gcodes.Unavailable, "transport closing"))
require.Equal(t, "daemon_unreachable", ce.Code)