-
Notifications
You must be signed in to change notification settings - Fork 980
chore: change cli error message handling #9952
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f2cbe77
7bf961a
c7efd8f
1af63f9
828737d
958e2a4
ef67b3d
4253615
b28c0cc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
package cli | ||
|
||
import ( | ||
"fmt" | ||
"net/http" | ||
"net/http/httptest" | ||
"os" | ||
|
||
"github.com/coder/coder/v2/cli/clibase" | ||
"github.com/coder/coder/v2/codersdk" | ||
"golang.org/x/xerrors" | ||
) | ||
|
||
func (RootCmd) errorExample() *clibase.Cmd { | ||
errorCmd := func(use string, err error) *clibase.Cmd { | ||
return &clibase.Cmd{ | ||
Use: use, | ||
Handler: func(inv *clibase.Invocation) error { | ||
return err | ||
}, | ||
} | ||
} | ||
|
||
// Make an api error | ||
recorder := httptest.NewRecorder() | ||
recorder.WriteHeader(http.StatusBadRequest) | ||
resp := recorder.Result() | ||
_ = resp.Body.Close() | ||
resp.Request, _ = http.NewRequest(http.MethodPost, "http://example.com", nil) | ||
apiError := codersdk.ReadBodyAsError(resp) | ||
//nolint:errorlint,forcetypeassert | ||
apiError.(*codersdk.Error).Response = codersdk.Response{ | ||
Message: "Top level sdk error message.", | ||
Detail: "magic dust unavailable, please try again later", | ||
Validations: []codersdk.ValidationError{ | ||
{ | ||
Field: "region", | ||
Detail: "magic dust is not available in your region", | ||
}, | ||
}, | ||
} | ||
//nolint:errorlint,forcetypeassert | ||
apiError.(*codersdk.Error).Helper = "Have you tried turning it off and on again?" | ||
|
||
// Some flags | ||
var magicWord clibase.String | ||
|
||
cmd := &clibase.Cmd{ | ||
Use: "example-error", | ||
Short: "Shows what different error messages look like", | ||
Long: "This command is pretty pointless, but without it testing errors is" + | ||
"difficult to visually inspect. Error message formatting is inherently" + | ||
"visual, so we need a way to quickly see what they look like.", | ||
Handler: func(inv *clibase.Invocation) error { | ||
return inv.Command.HelpHandler(inv) | ||
}, | ||
Children: []*clibase.Cmd{ | ||
// Typical codersdk api error | ||
errorCmd("api", apiError), | ||
|
||
// Typical cli error | ||
errorCmd("cmd", xerrors.Errorf("some error: %w", errorWithStackTrace())), | ||
|
||
// A multi-error | ||
{ | ||
Use: "multi-error", | ||
Handler: func(inv *clibase.Invocation) error { | ||
// Closing the stdin file descriptor will cause the next close | ||
// to fail. This is joined to the returned Command error. | ||
if f, ok := inv.Stdin.(*os.File); ok { | ||
_ = f.Close() | ||
} | ||
|
||
return xerrors.Errorf("some error: %w", errorWithStackTrace()) | ||
}, | ||
}, | ||
|
||
{ | ||
Use: "validation", | ||
Options: clibase.OptionSet{ | ||
clibase.Option{ | ||
Name: "magic-word", | ||
Description: "Take a good guess.", | ||
Required: true, | ||
Flag: "magic-word", | ||
Default: "", | ||
Value: clibase.Validate(&magicWord, func(value *clibase.String) error { | ||
return xerrors.Errorf("magic word is incorrect") | ||
}), | ||
}, | ||
}, | ||
Handler: func(i *clibase.Invocation) error { | ||
_, _ = fmt.Fprint(i.Stdout, "Try setting the --magic-word flag\n") | ||
return nil | ||
}, | ||
}, | ||
}, | ||
} | ||
|
||
return cmd | ||
} | ||
|
||
func errorWithStackTrace() error { | ||
return xerrors.Errorf("function decided not to work, and it never will") | ||
} | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -23,7 +23,6 @@ import ( | |
"text/tabwriter" | ||
"time" | ||
|
||
"github.com/charmbracelet/lipgloss" | ||
"github.com/mattn/go-isatty" | ||
"github.com/mitchellh/go-wordwrap" | ||
"golang.org/x/exp/slices" | ||
|
@@ -131,14 +130,13 @@ func (r *RootCmd) RunMain(subcommands []*clibase.Cmd) { | |
if err != nil { | ||
panic(err) | ||
} | ||
|
||
err = cmd.Invoke().WithOS().Run() | ||
if err != nil { | ||
if errors.Is(err, cliui.Canceled) { | ||
//nolint:revive | ||
os.Exit(1) | ||
} | ||
f := prettyErrorFormatter{w: os.Stderr} | ||
f := prettyErrorFormatter{w: os.Stderr, verbose: r.verbose} | ||
f.format(err) | ||
//nolint:revive | ||
os.Exit(1) | ||
|
@@ -951,67 +949,148 @@ func isConnectionError(err error) bool { | |
|
||
type prettyErrorFormatter struct { | ||
w io.Writer | ||
// verbose turns on more detailed error logs, such as stack traces. | ||
verbose bool | ||
} | ||
|
||
// format formats the error to the console. This error should be human | ||
// readable. | ||
func (p *prettyErrorFormatter) format(err error) { | ||
errTail := errors.Unwrap(err) | ||
output := cliHumanFormatError(err, &formatOpts{ | ||
Verbose: p.verbose, | ||
}) | ||
// always trail with a newline | ||
_, _ = p.w.Write([]byte(output + "\n")) | ||
} | ||
|
||
//nolint:errorlint | ||
if _, ok := err.(*clibase.RunCommandError); ok && errTail != nil { | ||
// Avoid extra nesting. | ||
p.format(errTail) | ||
return | ||
type formatOpts struct { | ||
Verbose bool | ||
} | ||
|
||
const indent = " " | ||
|
||
// cliHumanFormatError formats an error for the CLI. Newlines and styling are | ||
// included. | ||
func cliHumanFormatError(err error, opts *formatOpts) string { | ||
if opts == nil { | ||
opts = &formatOpts{} | ||
} | ||
|
||
var headErr string | ||
if errTail != nil { | ||
headErr = strings.TrimSuffix(err.Error(), ": "+errTail.Error()) | ||
} else { | ||
headErr = err.Error() | ||
//nolint:errorlint | ||
if multi, ok := err.(interface{ Unwrap() []error }); ok { | ||
multiErrors := multi.Unwrap() | ||
if len(multiErrors) == 1 { | ||
// Format as a single error | ||
return cliHumanFormatError(multiErrors[0], opts) | ||
} | ||
return formatMultiError(multiErrors, opts) | ||
} | ||
|
||
var msg string | ||
// First check for sentinel errors that we want to handle specially. | ||
// Order does matter! We want to check for the most specific errors first. | ||
var sdkError *codersdk.Error | ||
if errors.As(err, &sdkError) { | ||
// We don't want to repeat the same error message twice, so we | ||
// only show the SDK error on the top of the stack. | ||
msg = sdkError.Message | ||
if sdkError.Helper != "" { | ||
msg = msg + "\n" + sdkError.Helper | ||
} else if sdkError.Detail != "" { | ||
msg = msg + "\n" + sdkError.Detail | ||
return formatCoderSDKError(sdkError, opts) | ||
} | ||
|
||
var cmdErr *clibase.RunCommandError | ||
if errors.As(err, &cmdErr) { | ||
return formatRunCommandError(cmdErr, opts) | ||
} | ||
|
||
// Default just printing the error. Use +v for verbose to handle stack | ||
// traces of xerrors. | ||
if opts.Verbose { | ||
return pretty.Sprint(headLineStyle(), fmt.Sprintf("%+v", err)) | ||
} | ||
|
||
return pretty.Sprint(headLineStyle(), fmt.Sprintf("%v", err)) | ||
} | ||
|
||
// formatMultiError formats a multi-error. It formats it as a list of errors. | ||
// | ||
// Multiple Errors: | ||
// <# errors encountered>: | ||
// 1. <heading error message> | ||
// <verbose error message> | ||
// 2. <heading error message> | ||
// <verbose error message> | ||
func formatMultiError(multi []error, opts *formatOpts) string { | ||
var errorStrings []string | ||
for _, err := range multi { | ||
errorStrings = append(errorStrings, cliHumanFormatError(err, opts)) | ||
} | ||
|
||
// Write errors out | ||
var str strings.Builder | ||
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("%d errors encountered:", len(multi)))) | ||
for i, errStr := range errorStrings { | ||
// Indent each error | ||
errStr = strings.ReplaceAll(errStr, "\n", "\n"+indent) | ||
// Error now looks like | ||
// | <line> | ||
// | <line> | ||
prefix := fmt.Sprintf("%d. ", i+1) | ||
if len(prefix) < len(indent) { | ||
// Indent the prefix to match the indent | ||
prefix = prefix + strings.Repeat(" ", len(indent)-len(prefix)) | ||
} | ||
// The SDK error is usually good enough, and we don't want to overwhelm | ||
// the user with output. | ||
errTail = nil | ||
} else { | ||
msg = headErr | ||
errStr = prefix + errStr | ||
// Now looks like | ||
// |1.<line> | ||
// | <line> | ||
_, _ = str.WriteString("\n" + errStr) | ||
} | ||
return str.String() | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe this is the last remnant of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TIL, can do There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Lipgloss is still in the go.mod file because of slogtest: https://github.com/coder/slog/blob/main/internal/entryhuman/entry.go#L19 |
||
|
||
headStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#D16644")) | ||
p.printf( | ||
headStyle, | ||
"%s", | ||
msg, | ||
) | ||
// formatRunCommandError are cli command errors. This kind of error is very | ||
// broad, as it contains all errors that occur when running a command. | ||
// If you know the error is something else, like a codersdk.Error, make a new | ||
// formatter and add it to cliHumanFormatError function. | ||
func formatRunCommandError(err *clibase.RunCommandError, opts *formatOpts) string { | ||
var str strings.Builder | ||
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("Encountered an error running %q", err.Cmd.FullName()))) | ||
|
||
msgString := fmt.Sprintf("%v", err.Err) | ||
if opts.Verbose { | ||
// '%+v' includes stack traces | ||
msgString = fmt.Sprintf("%+v", err.Err) | ||
} | ||
_, _ = str.WriteString("\n") | ||
_, _ = str.WriteString(pretty.Sprint(tailLineStyle(), msgString)) | ||
return str.String() | ||
} | ||
|
||
tailStyle := headStyle.Copy().Foreground(lipgloss.Color("#969696")) | ||
// formatCoderSDKError come from API requests. In verbose mode, add the | ||
// request debug information. | ||
func formatCoderSDKError(err *codersdk.Error, opts *formatOpts) string { | ||
var str strings.Builder | ||
if opts.Verbose { | ||
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("API request error to \"%s:%s\". Status code %d", err.Method(), err.URL(), err.StatusCode()))) | ||
_, _ = str.WriteString("\n") | ||
} | ||
|
||
if errTail != nil { | ||
p.printf(headStyle, ": ") | ||
// Grey out the less important, deep errors. | ||
p.printf(tailStyle, "%s", errTail.Error()) | ||
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), err.Message)) | ||
if err.Helper != "" { | ||
_, _ = str.WriteString("\n") | ||
_, _ = str.WriteString(pretty.Sprint(tailLineStyle(), err.Helper)) | ||
} | ||
// By default we do not show the Detail with the helper. | ||
if opts.Verbose || (err.Helper == "" && err.Detail != "") { | ||
_, _ = str.WriteString("\n") | ||
_, _ = str.WriteString(pretty.Sprint(tailLineStyle(), err.Detail)) | ||
} | ||
p.printf(tailStyle, "\n") | ||
return str.String() | ||
} | ||
|
||
func (p *prettyErrorFormatter) printf(style lipgloss.Style, format string, a ...interface{}) { | ||
s := style.Render(fmt.Sprintf(format, a...)) | ||
_, _ = p.w.Write( | ||
[]byte( | ||
s, | ||
), | ||
) | ||
// These styles are arbitrary. | ||
func headLineStyle() pretty.Style { | ||
return cliui.DefaultStyles.Error | ||
} | ||
|
||
func tailLineStyle() pretty.Style { | ||
return pretty.Style{pretty.Nop} | ||
} | ||
|
||
//nolint:unused | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Re: lines 103 to 103]
Nifty
See this comment inline on Graphite.