Skip to content

Commit e127e9e

Browse files
committed
feat: user-friendly error messaging
This change introduces a new errors library we can use to surface user-friendly error messaging. When user errors are used, the internal technical details of the error message are not shown to the user unless they run the command with the verbose option. What the command will return now: ```shell $ datumctl activity query error: Authentication session has expired or refresh token is no longer valid. Please re-authenticate using: `datumctl auth login` ```
1 parent e3d9d09 commit e127e9e

3 files changed

Lines changed: 114 additions & 2 deletions

File tree

internal/authutil/credentials.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strings"
1111
"sync"
1212

13+
customerrors "go.datum.net/datumctl/internal/errors"
1314
"go.datum.net/datumctl/internal/keyring"
1415
"golang.org/x/oauth2"
1516
)
@@ -24,7 +25,10 @@ const ActiveUserKey = "active_user"
2425
const KnownUsersKey = "known_users"
2526

2627
// ErrNoActiveUser indicates that no active user is set in the keyring.
27-
var ErrNoActiveUser = errors.New("no active user set. Please login first")
28+
var ErrNoActiveUser = customerrors.NewUserErrorWithHint(
29+
"No active user found.",
30+
"Please login first using: `datumctl auth login`",
31+
)
2832

2933
// StoredCredentials holds all necessary information for a single authenticated session.
3034
type StoredCredentials struct {
@@ -113,7 +117,11 @@ func (p *persistingTokenSource) Token() (*oauth2.Token, error) {
113117
var retrieveErr *oauth2.RetrieveError
114118
if errors.As(err, &retrieveErr) {
115119
if retrieveErr.ErrorCode == "invalid_grant" || retrieveErr.ErrorCode == "invalid_request" {
116-
return nil, fmt.Errorf("Authentication session has expired or refresh token is no longer valid. Please re-authenticate using: `datumctl auth login`")
120+
return nil, customerrors.WrapUserErrorWithHint(
121+
"Authentication session has expired or refresh token is no longer valid.",
122+
"Please re-authenticate using: `datumctl auth login`",
123+
err,
124+
)
117125
}
118126
}
119127
return nil, err

internal/errors/errors.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Package errors provides custom error types for user-friendly error messaging.
2+
//
3+
// This package distinguishes between user-facing errors and technical errors,
4+
// allowing the CLI to display clean messages while preserving technical details
5+
// for debugging with verbose flags.
6+
package errors
7+
8+
import (
9+
"errors"
10+
"fmt"
11+
)
12+
13+
// UserError represents an error with a user-friendly message.
14+
//
15+
// UserError separates user-facing messages from technical implementation details,
16+
// making CLI output cleaner while preserving debugging information for verbose mode.
17+
type UserError struct {
18+
// Message is the user-friendly error message displayed to users.
19+
Message string
20+
21+
// Err is the underlying technical error, preserved for debugging
22+
// but hidden from normal output.
23+
Err error
24+
25+
// Hint provides actionable guidance to help users resolve the issue.
26+
Hint string
27+
}
28+
29+
// Error implements the error interface and returns the user-friendly message.
30+
//
31+
// If a hint is set, it appends the hint to the message on a new line.
32+
func (e *UserError) Error() string {
33+
if e.Hint != "" {
34+
return fmt.Sprintf("%s\n%s", e.Message, e.Hint)
35+
}
36+
return e.Message
37+
}
38+
39+
// Unwrap returns the underlying technical error for error chain inspection.
40+
func (e *UserError) Unwrap() error {
41+
return e.Err
42+
}
43+
44+
// IsUserError checks whether an error chain contains a UserError.
45+
//
46+
// It uses errors.As to walk the error chain and returns the first UserError found.
47+
// The second return value indicates whether a UserError was found.
48+
func IsUserError(err error) (*UserError, bool) {
49+
var userErr *UserError
50+
if errors.As(err, &userErr) {
51+
return userErr, true
52+
}
53+
return nil, false
54+
}
55+
56+
// NewUserError creates a user-facing error with a message.
57+
//
58+
// Use this for simple errors that don't need hints or wrapped technical errors.
59+
func NewUserError(message string) *UserError {
60+
return &UserError{Message: message}
61+
}
62+
63+
// NewUserErrorWithHint creates a user-facing error with a message and actionable hint.
64+
//
65+
// The hint should provide specific instructions to help users resolve the issue,
66+
// such as command examples or documentation links.
67+
func NewUserErrorWithHint(message, hint string) *UserError {
68+
return &UserError{Message: message, Hint: hint}
69+
}
70+
71+
// WrapUserError wraps a technical error with a user-friendly message.
72+
//
73+
// The technical error is preserved for debugging with verbose flags but hidden
74+
// from normal output.
75+
func WrapUserError(message string, err error) *UserError {
76+
return &UserError{Message: message, Err: err}
77+
}
78+
79+
// WrapUserErrorWithHint wraps a technical error with a user-friendly message and hint.
80+
//
81+
// This combines a clean user message, actionable guidance, and technical details
82+
// for debugging.
83+
func WrapUserErrorWithHint(message, hint string, err error) *UserError {
84+
return &UserError{Message: message, Hint: hint, Err: err}
85+
}

main.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package main
22

33
import (
4+
"fmt"
45
"os"
6+
"strconv"
57

68
"go.datum.net/datumctl/internal/cmd"
9+
customerrors "go.datum.net/datumctl/internal/errors"
710
"k8s.io/component-base/cli"
811
"k8s.io/component-base/logs"
912
kubectlcmd "k8s.io/kubectl/pkg/cmd"
@@ -14,6 +17,22 @@ func main() {
1417
logs.GlogSetter(kubectlcmd.GetLogVerbosity(os.Args))
1518
cmd := cmd.RootCmd()
1619
if err := cli.RunNoErrOutput(cmd); err != nil {
20+
// Check if this is a user-facing error
21+
if userErr, ok := customerrors.IsUserError(err); ok {
22+
// Print clean user-friendly error message
23+
fmt.Fprintf(os.Stderr, "error: %s\n", userErr.Error())
24+
25+
// Show technical details in verbose mode (v >= 4)
26+
verbosityStr := kubectlcmd.GetLogVerbosity(os.Args)
27+
verbosity, _ := strconv.Atoi(verbosityStr)
28+
if verbosity >= 4 && userErr.Err != nil {
29+
fmt.Fprintf(os.Stderr, "\nDetails:\n%v\n", userErr.Err)
30+
}
31+
32+
os.Exit(1)
33+
}
34+
35+
// Fall back to standard kubectl error handling for technical errors
1736
util.CheckErr(err)
1837
}
1938
}

0 commit comments

Comments
 (0)