Skip to content

Commit dd9fbb5

Browse files
committed
fix: show clear error and non-zero exit on non-JSON API responses
1 parent 07101ad commit dd9fbb5

2 files changed

Lines changed: 265 additions & 0 deletions

File tree

main.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ func main() {
221221
func run() (exitCode int) {
222222
// Reset per-invocation state so repeated calls (e.g. in tests) start clean.
223223
customerContextFlagValue = ""
224+
nonJSONErrorResponse = false
224225

225226
// Resolve agent mode once up front. Downstream behavior — color, default
226227
// output format, stderr routing, and the User-Agent mode token — all key off
@@ -261,6 +262,7 @@ func run() (exitCode int) {
261262
cli.Init("dci", version)
262263
cli.Defaults()
263264
overrideTableOutput()
265+
installResponseGuard()
264266
registerAgentFlags()
265267
printFirstRunOnboarding(configured)
266268
maybeHintAgentMode()
@@ -313,6 +315,11 @@ func run() (exitCode int) {
313315
return 1
314316
}
315317
code := cli.GetExitCode()
318+
// A non-JSON error page can arrive with a 2xx status, which restish would
319+
// treat as success. Force a non-zero exit so callers detect the failure.
320+
if code == 0 && nonJSONErrorResponse {
321+
code = 1
322+
}
316323
maybeHintDoerContext(code, cli.GetLastStatus(), configDir)
317324
return code
318325
}
@@ -1418,6 +1425,104 @@ func overrideTableOutput() {
14181425
cli.AddContentType("toon", "", -1, &dciToonContentType{})
14191426
}
14201427

1428+
// nonJSONErrorResponse records that the last API response was a non-JSON error
1429+
// page (typically HTML) so run() can force a non-zero exit even when the HTTP
1430+
// status was 2xx. Reset at the start of each run().
1431+
var nonJSONErrorResponse bool
1432+
1433+
// dciResponseGuard wraps restish's response formatter to catch non-JSON error
1434+
// pages before they are printed. The DCI API is JSON-only, but an upstream
1435+
// timeout or maintenance window can surface the Cloudflare edge's HTML error
1436+
// page (e.g. a 524 rendered as the DoiT maintenance page). Restish would dump
1437+
// that raw HTML to stdout, which is confusing and hard to script against, so we
1438+
// replace it with a clear, actionable message and a non-zero exit code.
1439+
type dciResponseGuard struct {
1440+
next cli.ResponseFormatter
1441+
}
1442+
1443+
func (g dciResponseGuard) Format(resp cli.Response) error {
1444+
if isHTMLErrorPage(resp) {
1445+
nonJSONErrorResponse = true
1446+
printNonJSONError(resp)
1447+
return nil
1448+
}
1449+
return g.next.Format(resp)
1450+
}
1451+
1452+
// installResponseGuard wraps the active restish formatter. It must run after
1453+
// cli.Defaults() (which sets cli.Formatter) and overrideTableOutput().
1454+
func installResponseGuard() {
1455+
if cli.Formatter == nil {
1456+
return
1457+
}
1458+
cli.Formatter = dciResponseGuard{next: cli.Formatter}
1459+
}
1460+
1461+
// isHTMLErrorPage reports whether a parsed response is an HTML page rather than
1462+
// the JSON the DCI API is expected to return. It checks the Content-Type header
1463+
// and, because edge error pages sometimes carry a misleading or absent type,
1464+
// sniffs the body for an HTML prefix. Streaming (text/event-stream) and JSON
1465+
// responses are intentionally left untouched.
1466+
func isHTMLErrorPage(resp cli.Response) bool {
1467+
if strings.Contains(strings.ToLower(headerValue(resp.Headers, "Content-Type")), "text/html") {
1468+
return true
1469+
}
1470+
switch b := resp.Body.(type) {
1471+
case string:
1472+
return bodyLooksLikeHTML(b)
1473+
case []byte:
1474+
return bodyLooksLikeHTML(string(b))
1475+
}
1476+
return false
1477+
}
1478+
1479+
func bodyLooksLikeHTML(s string) bool {
1480+
s = strings.ToLower(strings.TrimSpace(s))
1481+
return strings.HasPrefix(s, "<!doctype html") ||
1482+
strings.HasPrefix(s, "<html") ||
1483+
strings.HasPrefix(s, "<head") ||
1484+
strings.HasPrefix(s, "<body")
1485+
}
1486+
1487+
func printNonJSONError(resp cli.Response) {
1488+
var b strings.Builder
1489+
b.WriteString("Error: the DoiT API returned a non-JSON (HTML) response.\n")
1490+
b.WriteString("This usually means an upstream timeout or maintenance window rather than a problem with your request.\n")
1491+
if resp.Status != 0 {
1492+
fmt.Fprintf(&b, "HTTP status: %d\n", resp.Status)
1493+
}
1494+
if trace := traceID(resp.Headers); trace != "" {
1495+
fmt.Fprintf(&b, "Trace: %s\n", trace)
1496+
}
1497+
b.WriteString("Please retry in a moment. If it persists, contact DoiT support with the trace above.\n")
1498+
fmt.Fprint(os.Stderr, b.String())
1499+
}
1500+
1501+
// traceID returns the first available upstream trace identifier so users can
1502+
// reference it in a support request.
1503+
func traceID(headers map[string]string) string {
1504+
for _, name := range []string{"Cf-Ray", "X-Doit-Trace", "X-Request-Id", "X-Cloud-Trace-Context", "Traceparent"} {
1505+
if v := strings.TrimSpace(headerValue(headers, name)); v != "" {
1506+
return name + "=" + v
1507+
}
1508+
}
1509+
return ""
1510+
}
1511+
1512+
// headerValue looks up a header case-insensitively. Restish canonicalizes
1513+
// header keys, but sniffing defensively keeps this robust across sources.
1514+
func headerValue(headers map[string]string, name string) string {
1515+
if v, ok := headers[name]; ok {
1516+
return v
1517+
}
1518+
for k, v := range headers {
1519+
if strings.EqualFold(k, name) {
1520+
return v
1521+
}
1522+
}
1523+
return ""
1524+
}
1525+
14211526
func (t dciTableContentType) Detect(contentType string) bool { return false }
14221527

14231528
func (t dciTableContentType) Marshal(value interface{}) ([]byte, error) {

main_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2011,3 +2011,163 @@ func TestCustomerContextFlag(t *testing.T) {
20112011
}
20122012
})
20132013
}
2014+
2015+
func TestIsHTMLErrorPage(t *testing.T) {
2016+
tests := []struct {
2017+
name string
2018+
resp cli.Response
2019+
want bool
2020+
}{
2021+
{
2022+
name: "text/html content type",
2023+
resp: cli.Response{Headers: map[string]string{"Content-Type": "text/html; charset=utf-8"}, Body: "anything"},
2024+
want: true,
2025+
},
2026+
{
2027+
name: "doctype body without content type",
2028+
resp: cli.Response{Body: "<!DOCTYPE html><html><head><title>DoiT Console - Maintenance</title></head></html>"},
2029+
want: true,
2030+
},
2031+
{
2032+
name: "html body with leading whitespace",
2033+
resp: cli.Response{Body: "\n <html><body>oops</body></html>"},
2034+
want: true,
2035+
},
2036+
{
2037+
name: "html body as bytes",
2038+
resp: cli.Response{Body: []byte("<head>...</head>")},
2039+
want: true,
2040+
},
2041+
{
2042+
name: "json object body is not html",
2043+
resp: cli.Response{Headers: map[string]string{"Content-Type": "application/json"}, Body: map[string]interface{}{"answer": "hi"}},
2044+
want: false,
2045+
},
2046+
{
2047+
name: "json string body is not html",
2048+
resp: cli.Response{Body: `{"answer":"hi"}`},
2049+
want: false,
2050+
},
2051+
{
2052+
name: "streaming SSE body is not html",
2053+
resp: cli.Response{Headers: map[string]string{"Content-Type": "text/event-stream"}, Body: "data: {\"chunk\":1}"},
2054+
want: false,
2055+
},
2056+
{
2057+
name: "empty response is not html",
2058+
resp: cli.Response{},
2059+
want: false,
2060+
},
2061+
}
2062+
2063+
for _, tt := range tests {
2064+
t.Run(tt.name, func(t *testing.T) {
2065+
if got := isHTMLErrorPage(tt.resp); got != tt.want {
2066+
t.Errorf("isHTMLErrorPage() = %v, want %v", got, tt.want)
2067+
}
2068+
})
2069+
}
2070+
}
2071+
2072+
func TestTraceID(t *testing.T) {
2073+
tests := []struct {
2074+
name string
2075+
headers map[string]string
2076+
want string
2077+
}{
2078+
{name: "cf-ray preferred", headers: map[string]string{"Cf-Ray": "a023e448ca101c99", "X-Request-Id": "abc"}, want: "Cf-Ray=a023e448ca101c99"},
2079+
{name: "case insensitive lookup", headers: map[string]string{"cf-ray": "deadbeef"}, want: "Cf-Ray=deadbeef"},
2080+
{name: "falls back to request id", headers: map[string]string{"X-Request-Id": "req-123"}, want: "X-Request-Id=req-123"},
2081+
{name: "none present", headers: map[string]string{"Content-Type": "text/html"}, want: ""},
2082+
}
2083+
2084+
for _, tt := range tests {
2085+
t.Run(tt.name, func(t *testing.T) {
2086+
if got := traceID(tt.headers); got != tt.want {
2087+
t.Errorf("traceID() = %q, want %q", got, tt.want)
2088+
}
2089+
})
2090+
}
2091+
}
2092+
2093+
// recordingFormatter is a test double capturing whether the wrapped formatter
2094+
// was delegated to.
2095+
type recordingFormatter struct {
2096+
called bool
2097+
got cli.Response
2098+
}
2099+
2100+
func (r *recordingFormatter) Format(resp cli.Response) error {
2101+
r.called = true
2102+
r.got = resp
2103+
return nil
2104+
}
2105+
2106+
func TestResponseGuardFormat(t *testing.T) {
2107+
t.Run("html page prints message and sets flag without delegating", func(t *testing.T) {
2108+
nonJSONErrorResponse = false
2109+
t.Cleanup(func() { nonJSONErrorResponse = false })
2110+
2111+
next := &recordingFormatter{}
2112+
guard := dciResponseGuard{next: next}
2113+
2114+
r, w, _ := os.Pipe()
2115+
oldStderr := os.Stderr
2116+
os.Stderr = w
2117+
2118+
err := guard.Format(cli.Response{
2119+
Status: 524,
2120+
Headers: map[string]string{"Content-Type": "text/html", "Cf-Ray": "a023e448ca101c99"},
2121+
Body: "<!DOCTYPE html><html><head><title>DoiT Console - Maintenance</title></head></html>",
2122+
})
2123+
2124+
w.Close()
2125+
os.Stderr = oldStderr
2126+
buf := make([]byte, 4096)
2127+
n, _ := r.Read(buf)
2128+
output := string(buf[:n])
2129+
r.Close()
2130+
2131+
if err != nil {
2132+
t.Fatalf("unexpected error: %v", err)
2133+
}
2134+
if next.called {
2135+
t.Fatal("expected guard not to delegate to the wrapped formatter for HTML")
2136+
}
2137+
if !nonJSONErrorResponse {
2138+
t.Fatal("expected nonJSONErrorResponse to be set")
2139+
}
2140+
if !strings.Contains(output, "non-JSON") {
2141+
t.Errorf("expected message to mention non-JSON, got:\n%s", output)
2142+
}
2143+
if !strings.Contains(output, "HTTP status: 524") {
2144+
t.Errorf("expected message to include HTTP status, got:\n%s", output)
2145+
}
2146+
if !strings.Contains(output, "Cf-Ray=a023e448ca101c99") {
2147+
t.Errorf("expected message to include trace, got:\n%s", output)
2148+
}
2149+
})
2150+
2151+
t.Run("json response delegates to wrapped formatter", func(t *testing.T) {
2152+
nonJSONErrorResponse = false
2153+
t.Cleanup(func() { nonJSONErrorResponse = false })
2154+
2155+
next := &recordingFormatter{}
2156+
guard := dciResponseGuard{next: next}
2157+
2158+
resp := cli.Response{
2159+
Status: 200,
2160+
Headers: map[string]string{"Content-Type": "application/json"},
2161+
Body: map[string]interface{}{"answer": "hello"},
2162+
}
2163+
if err := guard.Format(resp); err != nil {
2164+
t.Fatalf("unexpected error: %v", err)
2165+
}
2166+
if !next.called {
2167+
t.Fatal("expected guard to delegate to the wrapped formatter for JSON")
2168+
}
2169+
if nonJSONErrorResponse {
2170+
t.Fatal("expected nonJSONErrorResponse to stay false for JSON")
2171+
}
2172+
})
2173+
}

0 commit comments

Comments
 (0)