Skip to content

Commit f5a9f5c

Browse files
authored
chore: handle errors in wsproxy server for cli using buildinfo (#11584)
Cli errors are pretty formatted. This handles nested pretty types. Before it found the first error it could understand and return that. Now it will print the full error stack with more information. To prevent information loss, a "[Trace=...]" was added to capture some extra error context for debugging.
1 parent aecdafd commit f5a9f5c

File tree

5 files changed

+114
-31
lines changed

5 files changed

+114
-31
lines changed

cli/errors.go

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cli
22

33
import (
4+
"errors"
45
"fmt"
56
"net/http"
67
"net/http/httptest"
@@ -43,6 +44,10 @@ func (RootCmd) errorExample() *clibase.Cmd {
4344
//nolint:errorlint,forcetypeassert
4445
apiError.(*codersdk.Error).Helper = "Have you tried turning it off and on again?"
4546

47+
//nolint:errorlint,forcetypeassert
48+
apiErrorNoHelper := apiError.(*codersdk.Error)
49+
apiErrorNoHelper.Helper = ""
50+
4651
// Some flags
4752
var magicWord clibase.String
4853

@@ -65,14 +70,28 @@ func (RootCmd) errorExample() *clibase.Cmd {
6570
// A multi-error
6671
{
6772
Use: "multi-error",
73+
Handler: func(inv *clibase.Invocation) error {
74+
return xerrors.Errorf("wrapped: %w", errors.Join(
75+
xerrors.Errorf("first error: %w", errorWithStackTrace()),
76+
xerrors.Errorf("second error: %w", errorWithStackTrace()),
77+
xerrors.Errorf("wrapped api error: %w", apiErrorNoHelper),
78+
))
79+
},
80+
},
81+
{
82+
Use: "multi-multi-error",
83+
Short: "This is a multi error inside a multi error",
6884
Handler: func(inv *clibase.Invocation) error {
6985
// Closing the stdin file descriptor will cause the next close
7086
// to fail. This is joined to the returned Command error.
7187
if f, ok := inv.Stdin.(*os.File); ok {
7288
_ = f.Close()
7389
}
7490

75-
return xerrors.Errorf("some error: %w", errorWithStackTrace())
91+
return errors.Join(
92+
xerrors.Errorf("first error: %w", errorWithStackTrace()),
93+
xerrors.Errorf("second error: %w", errorWithStackTrace()),
94+
)
7695
},
7796
},
7897

cli/root.go

+75-24
Original file line numberDiff line numberDiff line change
@@ -1016,7 +1016,7 @@ type prettyErrorFormatter struct {
10161016
// format formats the error to the console. This error should be human
10171017
// readable.
10181018
func (p *prettyErrorFormatter) format(err error) {
1019-
output := cliHumanFormatError(err, &formatOpts{
1019+
output, _ := cliHumanFormatError("", err, &formatOpts{
10201020
Verbose: p.verbose,
10211021
})
10221022
// always trail with a newline
@@ -1030,41 +1030,66 @@ type formatOpts struct {
10301030
const indent = " "
10311031

10321032
// cliHumanFormatError formats an error for the CLI. Newlines and styling are
1033-
// included.
1034-
func cliHumanFormatError(err error, opts *formatOpts) string {
1033+
// included. The second return value is true if the error is special and the error
1034+
// chain has custom formatting applied.
1035+
//
1036+
// If you change this code, you can use the cli "example-errors" tool to
1037+
// verify all errors still look ok.
1038+
//
1039+
// go run main.go exp example-error <type>
1040+
// go run main.go exp example-error api
1041+
// go run main.go exp example-error cmd
1042+
// go run main.go exp example-error multi-error
1043+
// go run main.go exp example-error validation
1044+
//
1045+
//nolint:errorlint
1046+
func cliHumanFormatError(from string, err error, opts *formatOpts) (string, bool) {
10351047
if opts == nil {
10361048
opts = &formatOpts{}
10371049
}
1050+
if err == nil {
1051+
return "<nil>", true
1052+
}
10381053

1039-
//nolint:errorlint
10401054
if multi, ok := err.(interface{ Unwrap() []error }); ok {
10411055
multiErrors := multi.Unwrap()
10421056
if len(multiErrors) == 1 {
10431057
// Format as a single error
1044-
return cliHumanFormatError(multiErrors[0], opts)
1058+
return cliHumanFormatError(from, multiErrors[0], opts)
10451059
}
1046-
return formatMultiError(multiErrors, opts)
1060+
return formatMultiError(from, multiErrors, opts), true
10471061
}
10481062

10491063
// First check for sentinel errors that we want to handle specially.
10501064
// Order does matter! We want to check for the most specific errors first.
1051-
var sdkError *codersdk.Error
1052-
if errors.As(err, &sdkError) {
1053-
return formatCoderSDKError(sdkError, opts)
1065+
if sdkError, ok := err.(*codersdk.Error); ok {
1066+
return formatCoderSDKError(from, sdkError, opts), true
10541067
}
10551068

1056-
var cmdErr *clibase.RunCommandError
1057-
if errors.As(err, &cmdErr) {
1058-
return formatRunCommandError(cmdErr, opts)
1069+
if cmdErr, ok := err.(*clibase.RunCommandError); ok {
1070+
// no need to pass the "from" context to this since it is always
1071+
// top level. We care about what is below this.
1072+
return formatRunCommandError(cmdErr, opts), true
10591073
}
10601074

1075+
uw, ok := err.(interface{ Unwrap() error })
1076+
if ok {
1077+
msg, special := cliHumanFormatError(from+traceError(err), uw.Unwrap(), opts)
1078+
if special {
1079+
return msg, special
1080+
}
1081+
}
1082+
// If we got here, that means that the wrapped error chain does not have
1083+
// any special formatting below it. So we want to return the topmost non-special
1084+
// error (which is 'err')
1085+
10611086
// Default just printing the error. Use +v for verbose to handle stack
10621087
// traces of xerrors.
10631088
if opts.Verbose {
1064-
return pretty.Sprint(headLineStyle(), fmt.Sprintf("%+v", err))
1089+
return pretty.Sprint(headLineStyle(), fmt.Sprintf("%+v", err)), false
10651090
}
10661091

1067-
return pretty.Sprint(headLineStyle(), fmt.Sprintf("%v", err))
1092+
return pretty.Sprint(headLineStyle(), fmt.Sprintf("%v", err)), false
10681093
}
10691094

10701095
// formatMultiError formats a multi-error. It formats it as a list of errors.
@@ -1075,15 +1100,20 @@ func cliHumanFormatError(err error, opts *formatOpts) string {
10751100
// <verbose error message>
10761101
// 2. <heading error message>
10771102
// <verbose error message>
1078-
func formatMultiError(multi []error, opts *formatOpts) string {
1103+
func formatMultiError(from string, multi []error, opts *formatOpts) string {
10791104
var errorStrings []string
10801105
for _, err := range multi {
1081-
errorStrings = append(errorStrings, cliHumanFormatError(err, opts))
1106+
msg, _ := cliHumanFormatError("", err, opts)
1107+
errorStrings = append(errorStrings, msg)
10821108
}
10831109

10841110
// Write errors out
10851111
var str strings.Builder
1086-
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("%d errors encountered:", len(multi))))
1112+
var traceMsg string
1113+
if from != "" {
1114+
traceMsg = fmt.Sprintf("Trace=[%s])", from)
1115+
}
1116+
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("%d errors encountered: %s", len(multi), traceMsg)))
10871117
for i, errStr := range errorStrings {
10881118
// Indent each error
10891119
errStr = strings.ReplaceAll(errStr, "\n", "\n"+indent)
@@ -1112,24 +1142,30 @@ func formatRunCommandError(err *clibase.RunCommandError, opts *formatOpts) strin
11121142
var str strings.Builder
11131143
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("Encountered an error running %q", err.Cmd.FullName())))
11141144

1115-
msgString := fmt.Sprintf("%v", err.Err)
1116-
if opts.Verbose {
1117-
// '%+v' includes stack traces
1118-
msgString = fmt.Sprintf("%+v", err.Err)
1119-
}
1145+
msgString, special := cliHumanFormatError("", err.Err, opts)
11201146
_, _ = str.WriteString("\n")
1121-
_, _ = str.WriteString(pretty.Sprint(tailLineStyle(), msgString))
1147+
if special {
1148+
_, _ = str.WriteString(msgString)
1149+
} else {
1150+
_, _ = str.WriteString(pretty.Sprint(tailLineStyle(), msgString))
1151+
}
1152+
11221153
return str.String()
11231154
}
11241155

11251156
// formatCoderSDKError come from API requests. In verbose mode, add the
11261157
// request debug information.
1127-
func formatCoderSDKError(err *codersdk.Error, opts *formatOpts) string {
1158+
func formatCoderSDKError(from string, err *codersdk.Error, opts *formatOpts) string {
11281159
var str strings.Builder
11291160
if opts.Verbose {
11301161
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("API request error to \"%s:%s\". Status code %d", err.Method(), err.URL(), err.StatusCode())))
11311162
_, _ = str.WriteString("\n")
11321163
}
1164+
// Always include this trace. Users can ignore this.
1165+
if from != "" {
1166+
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("Trace=[%s]", from)))
1167+
_, _ = str.WriteString("\n")
1168+
}
11331169

11341170
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), err.Message))
11351171
if err.Helper != "" {
@@ -1144,6 +1180,21 @@ func formatCoderSDKError(err *codersdk.Error, opts *formatOpts) string {
11441180
return str.String()
11451181
}
11461182

1183+
// traceError is a helper function that aides developers debugging failed cli
1184+
// commands. When we pretty print errors, we lose the context in which they came.
1185+
// This function adds the context back. Unfortunately there is no easy way to get
1186+
// the prefix to: "error string: %w", so we do a bit of string manipulation.
1187+
//
1188+
//nolint:errorlint
1189+
func traceError(err error) string {
1190+
if uw, ok := err.(interface{ Unwrap() error }); ok {
1191+
a, b := err.Error(), uw.Unwrap().Error()
1192+
c := strings.TrimSuffix(a, b)
1193+
return c
1194+
}
1195+
return err.Error()
1196+
}
1197+
11471198
// These styles are arbitrary.
11481199
func headLineStyle() pretty.Style {
11491200
return cliui.DefaultStyles.Error

codersdk/client.go

+13-4
Original file line numberDiff line numberDiff line change
@@ -323,14 +323,24 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac
323323
return resp, err
324324
}
325325

326+
// ExpectJSONMime is a helper function that will assert the content type
327+
// of the response is application/json.
328+
func ExpectJSONMime(res *http.Response) error {
329+
contentType := res.Header.Get("Content-Type")
330+
mimeType := parseMimeType(contentType)
331+
if mimeType != "application/json" {
332+
return xerrors.Errorf("unexpected non-JSON response %q", contentType)
333+
}
334+
return nil
335+
}
336+
326337
// ReadBodyAsError reads the response as a codersdk.Response, and
327338
// wraps it in a codersdk.Error type for easy marshaling.
328339
func ReadBodyAsError(res *http.Response) error {
329340
if res == nil {
330341
return xerrors.Errorf("no body returned")
331342
}
332343
defer res.Body.Close()
333-
contentType := res.Header.Get("Content-Type")
334344

335345
var requestMethod, requestURL string
336346
if res.Request != nil {
@@ -352,8 +362,7 @@ func ReadBodyAsError(res *http.Response) error {
352362
return xerrors.Errorf("read body: %w", err)
353363
}
354364

355-
mimeType := parseMimeType(contentType)
356-
if mimeType != "application/json" {
365+
if mimeErr := ExpectJSONMime(res); mimeErr != nil {
357366
if len(resp) > 2048 {
358367
resp = append(resp[:2048], []byte("...")...)
359368
}
@@ -365,7 +374,7 @@ func ReadBodyAsError(res *http.Response) error {
365374
method: requestMethod,
366375
url: requestURL,
367376
Response: Response{
368-
Message: fmt.Sprintf("unexpected non-JSON response %q", contentType),
377+
Message: mimeErr.Error(),
369378
Detail: string(resp),
370379
},
371380
Helper: helpMessage,

codersdk/deployment.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -2065,7 +2065,7 @@ func (c *Client) BuildInfo(ctx context.Context) (BuildInfoResponse, error) {
20652065
}
20662066
defer res.Body.Close()
20672067

2068-
if res.StatusCode != http.StatusOK {
2068+
if res.StatusCode != http.StatusOK || ExpectJSONMime(res) != nil {
20692069
return BuildInfoResponse{}, ReadBodyAsError(res)
20702070
}
20712071

enterprise/wsproxy/wsproxy.go

+5-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"crypto/tls"
66
"crypto/x509"
7+
"errors"
78
"fmt"
89
"net/http"
910
"net/url"
@@ -157,7 +158,10 @@ func New(ctx context.Context, opts *Options) (*Server, error) {
157158
// TODO: Probably do some version checking here
158159
info, err := client.SDKClient.BuildInfo(ctx)
159160
if err != nil {
160-
return nil, xerrors.Errorf("failed to fetch build info from %q: %w", opts.DashboardURL, err)
161+
return nil, fmt.Errorf("buildinfo: %w", errors.Join(
162+
fmt.Errorf("unable to fetch build info from primary coderd. Are you sure %q is a coderd instance?", opts.DashboardURL),
163+
err,
164+
))
161165
}
162166
if info.WorkspaceProxy {
163167
return nil, xerrors.Errorf("%q is a workspace proxy, not a primary coderd instance", opts.DashboardURL)

0 commit comments

Comments
 (0)