Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions cli/errors.go
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")
}
Copy link
Member

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.

1 change: 1 addition & 0 deletions cli/exp.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ func (r *RootCmd) expCmd() *clibase.Cmd {
Hidden: true,
Children: []*clibase.Cmd{
r.scaletestCmd(),
r.errorExample(),
},
}
return cmd
Expand Down
171 changes: 125 additions & 46 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is the last remnant of lipgloss in our codebase. If you could edit the code to use the pretty style that would be a nice side-win for the change.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL, can do

Copy link
Member Author

Choose a reason for hiding this comment

The 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
Expand Down
10 changes: 10 additions & 0 deletions codersdk/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,8 @@ func ReadBodyAsError(res *http.Response) error {
}
return &Error{
statusCode: res.StatusCode,
method: requestMethod,
url: requestURL,
Response: Response{
Message: fmt.Sprintf("unexpected non-JSON response %q", contentType),
Detail: string(resp),
Expand Down Expand Up @@ -414,6 +416,14 @@ func (e *Error) StatusCode() int {
return e.statusCode
}

func (e *Error) Method() string {
return e.method
}

func (e *Error) URL() string {
return e.url
}

func (e *Error) Friendly() string {
var sb strings.Builder
_, _ = fmt.Fprintf(&sb, "%s. %s", strings.TrimSuffix(e.Message, "."), e.Helper)
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ require (
github.com/charmbracelet/glamour v0.6.0
// In later at least v0.7.1, lipgloss changes its terminal detection
// which breaks most of our CLI golden files tests.
github.com/charmbracelet/lipgloss v0.8.0
github.com/charmbracelet/lipgloss v0.8.0 // indirect
github.com/cli/safeexec v1.0.1
github.com/codeclysm/extract/v3 v3.1.1
github.com/coder/flog v1.1.0
Expand Down