Skip to content

Commit e570f42

Browse files
authored
Merge pull request #70 from datum-cloud/feat/user-friendly-error-messaging
feat: user-friendly error messaging
2 parents e3d9d09 + e127e9e commit e570f42

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)