Skip to content

Commit 54b833e

Browse files
committed
fix: surface clear error and non-zero exit on non-JSON and 2xx-error API responses
1 parent e5dbe8e commit 54b833e

2 files changed

Lines changed: 48 additions & 17 deletions

File tree

main.go

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,14 +1228,15 @@ func registerAuthCommands(configDir string) {
12281228
if os.Getenv("DCI_API_KEY") != "" {
12291229
return fmt.Errorf("login is not needed when DCI_API_KEY is set")
12301230
}
1231-
// Trigger the OAuth flow by calling a lightweight endpoint.
1232-
// Suppress the validate response body — login only needs the OAuth
1233-
// side effect (token cached), not the API output.
1231+
// Trigger the OAuth flow via validate. This call is internal (login
1232+
// only needs the cached token), so isolate all its observable effects
1233+
// — stdout, guard stderr, guard exit-code flag — from the login result.
12341234
os.Args = []string{os.Args[0], "dci", "validate"}
1235-
oldOut := cli.Stdout
1236-
cli.Stdout = io.Discard
1235+
oldOut, oldErr := cli.Stdout, cli.Stderr
1236+
cli.Stdout, cli.Stderr = io.Discard, io.Discard
12371237
err := cli.Run()
1238-
cli.Stdout = oldOut
1238+
cli.Stdout, cli.Stderr = oldOut, oldErr
1239+
nonJSONErrorResponse = false
12391240

12401241
// Auto-configure DoiT employees who have no customer context set.
12411242
// The validate endpoint requires customerContext for @doit.com accounts,
@@ -1443,12 +1444,12 @@ func (g dciResponseGuard) Format(resp cli.Response) error {
14431444
return nil
14441445
}
14451446
if msg, ok := jsonApplicationError(resp); ok {
1446-
// Print the structured error body as usual, then flag a non-zero exit.
14471447
nonJSONErrorResponse = true
14481448
if err := g.next.Format(resp); err != nil {
14491449
return err
14501450
}
1451-
fmt.Fprintf(os.Stderr, "Error: the DoiT API returned an application error: %s\n", msg)
1451+
// cli.Stderr (not os.Stderr) so callers like login can suppress it; don't revert.
1452+
fmt.Fprintf(cli.Stderr, "Error: the DoiT API returned an application error: %s\n", msg)
14521453
return nil
14531454
}
14541455
return g.next.Format(resp)
@@ -1480,17 +1481,30 @@ func isHTMLErrorPage(resp cli.Response) bool {
14801481
}
14811482

14821483
func bodyLooksLikeHTML(s string) bool {
1483-
s = strings.ToLower(strings.TrimSpace(s))
1484+
s = strings.TrimSpace(strings.TrimPrefix(s, "\ufeff")) // strip UTF-8 BOM some proxies prepend
1485+
if len(s) > 16 {
1486+
s = s[:16] // only the prefix matters; avoid lowercasing the whole body
1487+
}
1488+
s = strings.ToLower(s)
14841489
return strings.HasPrefix(s, "<!doctype html") ||
14851490
strings.HasPrefix(s, "<html") ||
14861491
strings.HasPrefix(s, "<head") ||
1487-
strings.HasPrefix(s, "<body")
1492+
strings.HasPrefix(s, "<body") ||
1493+
strings.HasPrefix(s, "<?xml") ||
1494+
strings.HasPrefix(s, "<!--")
14881495
}
14891496

14901497
// jsonApplicationError reports whether a 2xx response carries a non-empty
14911498
// top-level JSON `error` field (e.g. an AVA askSync failure under a locked 2xx
14921499
// status), returning a human-readable message. Such a body is not a valid DCI
14931500
// success shape, so it is treated as a failure.
1501+
//
1502+
// The object-only, top-level-only scoping is load-bearing, verified against the
1503+
// OpenAPI spec: the only other 2xx body carrying a top-level `error` is
1504+
// POST /insights/v1/results, whose 200 is a top-level *array* of per-row error
1505+
// items. The map assertion below lets that array pass through untouched. Do not
1506+
// recurse into arrays or nested objects here, or it will false-positive on
1507+
// legitimate partial-result responses.
14941508
func jsonApplicationError(resp cli.Response) (string, bool) {
14951509
if resp.Status < 200 || resp.Status >= 300 {
14961510
return "", false
@@ -1506,6 +1520,7 @@ func jsonApplicationError(resp cli.Response) (string, bool) {
15061520
}
15071521
return v, true
15081522
case map[string]interface{}:
1523+
// Defensive only: no current DCI endpoint returns an object-typed error (AVA returns a string).
15091524
if len(v) == 0 {
15101525
return "", false
15111526
}
@@ -1528,7 +1543,8 @@ func printNonJSONError(resp cli.Response) {
15281543
fmt.Fprintf(&b, "Trace: %s\n", trace)
15291544
}
15301545
b.WriteString("Please retry in a moment. If it persists, contact DoiT support with the trace above.\n")
1531-
fmt.Fprint(os.Stderr, b.String())
1546+
// cli.Stderr (not os.Stderr) so internal callers like login can redirect it.
1547+
fmt.Fprint(cli.Stderr, b.String())
15321548
}
15331549

15341550
// traceID returns the first available upstream trace identifier so users can

main_test.go

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2038,6 +2038,21 @@ func TestIsHTMLErrorPage(t *testing.T) {
20382038
resp: cli.Response{Body: []byte("<head>...</head>")},
20392039
want: true,
20402040
},
2041+
{
2042+
name: "xml declaration edge page without content type",
2043+
resp: cli.Response{Body: "<?xml version=\"1.0\"?><error>upstream</error>"},
2044+
want: true,
2045+
},
2046+
{
2047+
name: "html comment edge page without content type",
2048+
resp: cli.Response{Body: "<!-- error --><html></html>"},
2049+
want: true,
2050+
},
2051+
{
2052+
name: "utf-8 bom before doctype",
2053+
resp: cli.Response{Body: "\ufeff<!DOCTYPE html><html></html>"},
2054+
want: true,
2055+
},
20412056
{
20422057
name: "json object body is not html",
20432058
resp: cli.Response{Headers: map[string]string{"Content-Type": "application/json"}, Body: map[string]interface{}{"answer": "hi"}},
@@ -2112,8 +2127,8 @@ func TestResponseGuardFormat(t *testing.T) {
21122127
guard := dciResponseGuard{next: next}
21132128

21142129
r, w, _ := os.Pipe()
2115-
oldStderr := os.Stderr
2116-
os.Stderr = w
2130+
oldStderr := cli.Stderr
2131+
cli.Stderr = w
21172132

21182133
err := guard.Format(cli.Response{
21192134
Status: 524,
@@ -2122,7 +2137,7 @@ func TestResponseGuardFormat(t *testing.T) {
21222137
})
21232138

21242139
w.Close()
2125-
os.Stderr = oldStderr
2140+
cli.Stderr = oldStderr
21262141
buf := make([]byte, 4096)
21272142
n, _ := r.Read(buf)
21282143
output := string(buf[:n])
@@ -2179,8 +2194,8 @@ func TestResponseGuardFormat(t *testing.T) {
21792194
guard := dciResponseGuard{next: next}
21802195

21812196
r, w, _ := os.Pipe()
2182-
oldStderr := os.Stderr
2183-
os.Stderr = w
2197+
oldStderr := cli.Stderr
2198+
cli.Stderr = w
21842199

21852200
err := guard.Format(cli.Response{
21862201
Status: 200,
@@ -2189,7 +2204,7 @@ func TestResponseGuardFormat(t *testing.T) {
21892204
})
21902205

21912206
w.Close()
2192-
os.Stderr = oldStderr
2207+
cli.Stderr = oldStderr
21932208
buf := make([]byte, 4096)
21942209
n, _ := r.Read(buf)
21952210
output := string(buf[:n])

0 commit comments

Comments
 (0)