errors.go RawHistoryFolder
1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package client
4
5import (
6 "errors"
7 "regexp"
8 "strings"
9
10 "connectrpc.com/connect"
11)
12
13// redirected matches the message Connect gives to a 3xx answer.
14var redirected = regexp.MustCompile(`\b30[1-8]\b`)
15
16// maxMessage caps a server message echoed to the terminal.
17const maxMessage = 300
18
19// Error is a failed call described for a person: one line, with a hint when
20// the code points to a likely cause. It wraps the original error.
21type Error struct {
22 msg string
23 err error
24}
25
26// Error implements error.
27func (e *Error) Error() string { return e.msg }
28
29// Unwrap returns the original error, a *connect.Error for server answers.
30func (e *Error) Unwrap() error { return e.err }
31
32// Describe turns an error of a call into a friendly one-line *Error. Errors
33// that are not Connect errors are returned unchanged, nil stays nil.
34func (c *Client) Describe(err error) error {
35 var ce *connect.Error
36 if err == nil || !errors.As(err, &ce) {
37 return err
38 }
39 msg := oneLine(ce.Message())
40 with := func(prefix string) string {
41 if msg == "" {
42 return prefix
43 }
44 return prefix + ": " + msg
45 }
46 var text string
47 switch ce.Code() {
48 case connect.CodeUnauthenticated:
49 if c.cfg.Token == "" {
50 text = "authentication required: set " + EnvToken + " to a valid token (create one with `ermite token create`)"
51 } else {
52 text = "not authenticated: " + EnvToken + " is invalid, expired or revoked (create a new one with `ermite token create`)"
53 }
54 case connect.CodePermissionDenied:
55 text = with("permission denied") + " (the token lacks a required scope, see `ermite token list`)"
56 case connect.CodeNotFound:
57 text = with("not found")
58 case connect.CodeAlreadyExists:
59 text = with("already exists")
60 case connect.CodeInvalidArgument:
61 if !connect.IsWireError(err) {
62 // Raised by the client library: the answer was cut or garbled.
63 text = "the connection to " + c.cfg.URL + " broke or the answer was malformed"
64 if msg != "" {
65 text += ": " + msg
66 }
67 break
68 }
69 text = with("invalid request")
70 case connect.CodeFailedPrecondition:
71 text = with("precondition failed")
72 case connect.CodeUnavailable:
73 text = "cannot reach the server at " + c.cfg.URL
74 if msg != "" {
75 text += ": " + msg
76 }
77 case connect.CodeDeadlineExceeded:
78 text = "the request to " + c.cfg.URL + " timed out"
79 case connect.CodeCanceled:
80 text = "canceled"
81 case connect.CodeUnimplemented:
82 text = "the server does not implement this call: check that " + EnvURL + " points to an ermite server, and its version with `ermite version`"
83 case connect.CodeResourceExhausted:
84 text = with("too many requests")
85 case connect.CodeInternal:
86 text = with("server error")
87 default:
88 text = with("unexpected server response")
89 if redirected.MatchString(msg) {
90 text += " (the server redirected the request: set " + EnvURL + " to the final address, probably https://)"
91 }
92 }
93 return &Error{msg: text, err: err}
94}
95
96// Clean makes a server-provided string safe for a terminal: line breaks and
97// tabs become spaces; other control characters (escape sequences) and bidi
98// override or isolate characters are replaced by "?".
99func Clean(s string) string {
100 return strings.Map(func(r rune) rune {
101 switch {
102 case r == '\t' || r == '\n' || r == '\r':
103 return ' '
104 case r < 0x20 || r == 0x7f || (r >= 0x80 && r < 0xa0),
105 r >= 0x202a && r <= 0x202e, r >= 0x2066 && r <= 0x2069:
106 return '?'
107 }
108 return r
109 }, s)
110}
111
112// oneLine collapses whitespace and bounds the length of a server message.
113func oneLine(s string) string {
114 s = strings.Join(strings.Fields(Clean(s)), " ")
115 if len(s) > maxMessage {
116 s = strings.ToValidUTF8(s[:maxMessage], "") + "…"
117 }
118 return s
119}