Skip to content

Commit 733df0d

Browse files
committed
[feat] Agent friendly error handling
1 parent ae23ad3 commit 733df0d

8 files changed

Lines changed: 364 additions & 22 deletions

File tree

internal/errors/suggestions.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package errors
2+
3+
import "strings"
4+
5+
var suggestions = map[int]string{
6+
401: "Your access token may be invalid or expired. Run `ldcli login` or set LD_ACCESS_TOKEN. " +
7+
"Create a new token at {baseURI}/settings/authorization.",
8+
403: "You don't have permission for this action. Check your access token's role " +
9+
"and custom role policies in LaunchDarkly settings.",
10+
404: "Resource not found. Verify the project key, flag key, or environment key. " +
11+
"Use `ldcli projects list` or `ldcli flags list` to see available resources.",
12+
409: "Conflict: the resource may have been modified since you last read it. " +
13+
"Fetch the latest version and retry.",
14+
429: "Rate limited. Wait a moment and retry. If this persists, check your account's " +
15+
"rate limit allocation.",
16+
}
17+
18+
func SuggestionForStatus(statusCode int, baseURI string) string {
19+
s, ok := suggestions[statusCode]
20+
if !ok {
21+
return ""
22+
}
23+
return strings.ReplaceAll(s, "{baseURI}", baseURI)
24+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package errors_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
8+
"github.com/launchdarkly/ldcli/internal/errors"
9+
)
10+
11+
func TestSuggestionForStatus(t *testing.T) {
12+
t.Run("401 returns access token suggestion with baseURI", func(t *testing.T) {
13+
result := errors.SuggestionForStatus(401, "https://app.launchdarkly.com")
14+
15+
assert.Contains(t, result, "ldcli login")
16+
assert.Contains(t, result, "LD_ACCESS_TOKEN")
17+
assert.Contains(t, result, "https://app.launchdarkly.com/settings/authorization")
18+
assert.NotContains(t, result, "{baseURI}")
19+
})
20+
21+
t.Run("403 returns permission suggestion", func(t *testing.T) {
22+
result := errors.SuggestionForStatus(403, "https://app.launchdarkly.com")
23+
24+
assert.Contains(t, result, "permission")
25+
assert.Contains(t, result, "custom role")
26+
})
27+
28+
t.Run("404 returns resource not found suggestion", func(t *testing.T) {
29+
result := errors.SuggestionForStatus(404, "https://app.launchdarkly.com")
30+
31+
assert.Contains(t, result, "not found")
32+
assert.Contains(t, result, "ldcli projects list")
33+
assert.Contains(t, result, "ldcli flags list")
34+
})
35+
36+
t.Run("409 returns conflict suggestion", func(t *testing.T) {
37+
result := errors.SuggestionForStatus(409, "https://app.launchdarkly.com")
38+
39+
assert.Contains(t, result, "Conflict")
40+
assert.Contains(t, result, "latest version")
41+
})
42+
43+
t.Run("429 returns rate limit suggestion", func(t *testing.T) {
44+
result := errors.SuggestionForStatus(429, "https://app.launchdarkly.com")
45+
46+
assert.Contains(t, result, "Rate limited")
47+
assert.Contains(t, result, "retry")
48+
})
49+
50+
t.Run("unknown status code returns empty string", func(t *testing.T) {
51+
result := errors.SuggestionForStatus(502, "https://app.launchdarkly.com")
52+
53+
assert.Empty(t, result)
54+
})
55+
56+
t.Run("200 returns empty string", func(t *testing.T) {
57+
result := errors.SuggestionForStatus(200, "https://app.launchdarkly.com")
58+
59+
assert.Empty(t, result)
60+
})
61+
62+
t.Run("replaces baseURI placeholder with actual value", func(t *testing.T) {
63+
result := errors.SuggestionForStatus(401, "https://custom.ld.example.com")
64+
65+
assert.Contains(t, result, "https://custom.ld.example.com/settings/authorization")
66+
assert.NotContains(t, result, "{baseURI}")
67+
})
68+
69+
t.Run("empty baseURI does not panic", func(t *testing.T) {
70+
result := errors.SuggestionForStatus(401, "")
71+
72+
assert.Contains(t, result, "ldcli login")
73+
assert.Contains(t, result, "/settings/authorization")
74+
})
75+
}

internal/output/output_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,21 @@ func TestCmdOutputSingular(t *testing.T) {
7171
fn: output.ErrorPlaintextOutputFn,
7272
input: `{}`,
7373
},
74+
"with an error with a suggestion": {
75+
expected: "Not Found (code: not_found)\nSuggestion: Check the resource key.",
76+
fn: output.ErrorPlaintextOutputFn,
77+
input: `{"code": "not_found", "message": "Not Found", "suggestion": "Check the resource key."}`,
78+
},
79+
"with an error with an empty suggestion": {
80+
expected: "Internal Server Error (code: internal_server_error)",
81+
fn: output.ErrorPlaintextOutputFn,
82+
input: `{"code": "internal_server_error", "message": "Internal Server Error", "suggestion": ""}`,
83+
},
84+
"with an error with only a message and a suggestion": {
85+
expected: "an error\nSuggestion: Try again.",
86+
fn: output.ErrorPlaintextOutputFn,
87+
input: `{"message": "an error", "suggestion": "Try again."}`,
88+
},
7489
"with a singular resource": {
7590
expected: "test-name (test-key)",
7691
fn: output.SingularPlaintextOutputFn,

internal/output/plaintext_fns.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,24 @@ var ConfigPlaintextOutputFn = func(r resource) string {
2828
// An error response could have a code and message or just a message. It's also possible that
2929
// there isn't either property.
3030
var ErrorPlaintextOutputFn = func(r resource) string {
31+
var parts []string
32+
3133
switch {
3234
case r["code"] == nil && (r["message"] == "" || r["message"] == nil):
33-
return "unknown error occurred"
35+
parts = append(parts, "unknown error occurred")
3436
case r["code"] == nil:
35-
return r["message"].(string)
37+
parts = append(parts, fmt.Sprint(r["message"]))
3638
case r["message"] == "":
37-
return fmt.Sprintf("an error occurred (code: %s)", r["code"])
39+
parts = append(parts, fmt.Sprintf("an error occurred (code: %v)", r["code"]))
3840
default:
39-
return fmt.Sprintf("%s (code: %s)", r["message"], r["code"])
41+
parts = append(parts, fmt.Sprintf("%v (code: %v)", r["message"], r["code"]))
42+
}
43+
44+
if suggestion, ok := r["suggestion"]; ok && suggestion != nil && suggestion != "" {
45+
parts = append(parts, fmt.Sprintf("\nSuggestion: %s", suggestion))
4046
}
47+
48+
return strings.Join(parts, "")
4149
}
4250

4351
// MultiplePlaintextOutputFn converts the resource to plain text.

internal/output/plaintext_fns_internal_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,32 @@ import (
66
"github.com/stretchr/testify/assert"
77
)
88

9+
func TestErrorPlaintextOutputFn(t *testing.T) {
10+
t.Run("with a non-string message does not panic", func(t *testing.T) {
11+
r := resource{"message": float64(404)}
12+
13+
result := ErrorPlaintextOutputFn(r)
14+
15+
assert.Equal(t, "404", result)
16+
})
17+
18+
t.Run("with a non-string code renders via fmt formatting", func(t *testing.T) {
19+
r := resource{"code": 123, "message": "an error"}
20+
21+
result := ErrorPlaintextOutputFn(r)
22+
23+
assert.Equal(t, "an error (code: 123)", result)
24+
})
25+
26+
t.Run("with non-string message and code does not panic", func(t *testing.T) {
27+
r := resource{"code": true, "message": 42}
28+
29+
result := ErrorPlaintextOutputFn(r)
30+
31+
assert.Equal(t, "42 (code: true)", result)
32+
})
33+
}
34+
935
func TestSingularPlaintextOutputFn(t *testing.T) {
1036
tests := map[string]struct {
1137
resource resource

internal/output/resource_output_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,4 +360,38 @@ func TestCmdOutputError(t *testing.T) {
360360
assert.Equal(t, "something went wrong", result)
361361
})
362362
})
363+
364+
t.Run("with an error containing statusCode and suggestion", func(t *testing.T) {
365+
errJSON := `{"code":"not_found","message":"Not Found","statusCode":404,"suggestion":"Resource not found. Use ldcli flags list."}`
366+
367+
t.Run("with JSON output includes all fields", func(t *testing.T) {
368+
err := errors.NewError(errJSON)
369+
370+
result := output.CmdOutputError("json", err)
371+
372+
assert.JSONEq(t, errJSON, result)
373+
})
374+
375+
t.Run("with plaintext output includes suggestion line", func(t *testing.T) {
376+
err := errors.NewError(errJSON)
377+
378+
result := output.CmdOutputError("plaintext", err)
379+
380+
assert.Contains(t, result, "Not Found (code: not_found)")
381+
assert.Contains(t, result, "\nSuggestion: Resource not found. Use ldcli flags list.")
382+
})
383+
})
384+
385+
t.Run("with an error with statusCode but no suggestion", func(t *testing.T) {
386+
errJSON := `{"code":"internal_server_error","message":"Internal Server Error","statusCode":500}`
387+
388+
t.Run("with plaintext output does not include suggestion line", func(t *testing.T) {
389+
err := errors.NewError(errJSON)
390+
391+
result := output.CmdOutputError("plaintext", err)
392+
393+
assert.Equal(t, "Internal Server Error (code: internal_server_error)", result)
394+
assert.NotContains(t, result, "Suggestion:")
395+
})
396+
})
363397
}

internal/resources/client.go

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"io"
88
"net/http"
99
"net/url"
10+
"strings"
1011

1112
"github.com/launchdarkly/ldcli/internal/errors"
1213
)
@@ -73,18 +74,39 @@ func (c ResourcesClient) MakeRequest(
7374
return body, nil
7475
}
7576

77+
var baseURI string
78+
if parsed, err := url.Parse(path); err == nil && parsed.Scheme != "" {
79+
baseURI = parsed.Scheme + "://" + parsed.Host
80+
}
81+
7682
if len(body) > 0 {
83+
var errMap map[string]interface{}
84+
if err := json.Unmarshal(body, &errMap); err != nil {
85+
errMap = map[string]interface{}{
86+
"code": strings.ToLower(strings.ReplaceAll(http.StatusText(res.StatusCode), " ", "_")),
87+
"message": string(body),
88+
"statusCode": res.StatusCode,
89+
}
90+
} else {
91+
if _, exists := errMap["statusCode"]; !exists {
92+
errMap["statusCode"] = res.StatusCode
93+
}
94+
}
95+
if suggestion := errors.SuggestionForStatus(res.StatusCode, baseURI); suggestion != "" {
96+
errMap["suggestion"] = suggestion
97+
}
98+
body, _ = json.Marshal(errMap)
7799
return body, errors.NewError(string(body))
78100
}
79101

80-
switch res.StatusCode {
81-
case http.StatusMethodNotAllowed:
82-
resp, _ := json.Marshal(map[string]string{
83-
"code": "method_not_allowed",
84-
"message": "method not allowed",
85-
})
86-
return body, errors.NewError(string(resp))
87-
default:
88-
return body, errors.NewError(fmt.Sprintf("could not complete the request: %d", res.StatusCode))
102+
errMap := map[string]interface{}{
103+
"code": strings.ToLower(strings.ReplaceAll(http.StatusText(res.StatusCode), " ", "_")),
104+
"message": http.StatusText(res.StatusCode),
105+
"statusCode": res.StatusCode,
106+
}
107+
if suggestion := errors.SuggestionForStatus(res.StatusCode, baseURI); suggestion != "" {
108+
errMap["suggestion"] = suggestion
89109
}
110+
resp, _ := json.Marshal(errMap)
111+
return body, errors.NewError(string(resp))
90112
}

0 commit comments

Comments
 (0)