Skip to content

Commit e5dbe8e

Browse files
committed
trim inline comment
1 parent dd9fbb5 commit e5dbe8e

2 files changed

Lines changed: 152 additions & 16 deletions

File tree

main.go

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -315,8 +315,7 @@ func run() (exitCode int) {
315315
return 1
316316
}
317317
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.
318+
// Force a non-zero exit when a 2xx response carried an error page/body.
320319
if code == 0 && nonJSONErrorResponse {
321320
code = 1
322321
}
@@ -1425,17 +1424,14 @@ func overrideTableOutput() {
14251424
cli.AddContentType("toon", "", -1, &dciToonContentType{})
14261425
}
14271426

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().
1427+
// nonJSONErrorResponse flags that the last API response was an error page/body
1428+
// so run() can force a non-zero exit even on a 2xx status. Reset each run().
14311429
var nonJSONErrorResponse bool
14321430

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.
1431+
// dciResponseGuard wraps restish's formatter to catch error responses the DCI
1432+
// API can leak as non-JSON HTML (e.g. a Cloudflare 524 maintenance page) or as
1433+
// a JSON error body under a locked 2xx status, replacing restish's success
1434+
// handling with a clear message and a non-zero exit code.
14391435
type dciResponseGuard struct {
14401436
next cli.ResponseFormatter
14411437
}
@@ -1446,6 +1442,15 @@ func (g dciResponseGuard) Format(resp cli.Response) error {
14461442
printNonJSONError(resp)
14471443
return nil
14481444
}
1445+
if msg, ok := jsonApplicationError(resp); ok {
1446+
// Print the structured error body as usual, then flag a non-zero exit.
1447+
nonJSONErrorResponse = true
1448+
if err := g.next.Format(resp); err != nil {
1449+
return err
1450+
}
1451+
fmt.Fprintf(os.Stderr, "Error: the DoiT API returned an application error: %s\n", msg)
1452+
return nil
1453+
}
14491454
return g.next.Format(resp)
14501455
}
14511456

@@ -1458,11 +1463,9 @@ func installResponseGuard() {
14581463
cli.Formatter = dciResponseGuard{next: cli.Formatter}
14591464
}
14601465

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+
// isHTMLErrorPage reports whether a response is HTML rather than JSON, checking
1467+
// the Content-Type and sniffing the body (edge error pages can carry a
1468+
// misleading or absent type). JSON and SSE responses are left untouched.
14661469
func isHTMLErrorPage(resp cli.Response) bool {
14671470
if strings.Contains(strings.ToLower(headerValue(resp.Headers, "Content-Type")), "text/html") {
14681471
return true
@@ -1484,6 +1487,36 @@ func bodyLooksLikeHTML(s string) bool {
14841487
strings.HasPrefix(s, "<body")
14851488
}
14861489

1490+
// jsonApplicationError reports whether a 2xx response carries a non-empty
1491+
// top-level JSON `error` field (e.g. an AVA askSync failure under a locked 2xx
1492+
// status), returning a human-readable message. Such a body is not a valid DCI
1493+
// success shape, so it is treated as a failure.
1494+
func jsonApplicationError(resp cli.Response) (string, bool) {
1495+
if resp.Status < 200 || resp.Status >= 300 {
1496+
return "", false
1497+
}
1498+
body, ok := resp.Body.(map[string]interface{})
1499+
if !ok {
1500+
return "", false
1501+
}
1502+
switch v := body["error"].(type) {
1503+
case string:
1504+
if strings.TrimSpace(v) == "" {
1505+
return "", false
1506+
}
1507+
return v, true
1508+
case map[string]interface{}:
1509+
if len(v) == 0 {
1510+
return "", false
1511+
}
1512+
if m, ok := v["message"].(string); ok && strings.TrimSpace(m) != "" {
1513+
return m, true
1514+
}
1515+
return "application error", true
1516+
}
1517+
return "", false
1518+
}
1519+
14871520
func printNonJSONError(resp cli.Response) {
14881521
var b strings.Builder
14891522
b.WriteString("Error: the DoiT API returned a non-JSON (HTML) response.\n")

main_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2170,4 +2170,107 @@ func TestResponseGuardFormat(t *testing.T) {
21702170
t.Fatal("expected nonJSONErrorResponse to stay false for JSON")
21712171
}
21722172
})
2173+
2174+
t.Run("2xx json error body prints body and flags non-zero exit", func(t *testing.T) {
2175+
nonJSONErrorResponse = false
2176+
t.Cleanup(func() { nonJSONErrorResponse = false })
2177+
2178+
next := &recordingFormatter{}
2179+
guard := dciResponseGuard{next: next}
2180+
2181+
r, w, _ := os.Pipe()
2182+
oldStderr := os.Stderr
2183+
os.Stderr = w
2184+
2185+
err := guard.Format(cli.Response{
2186+
Status: 200,
2187+
Headers: map[string]string{"Content-Type": "application/json"},
2188+
Body: map[string]interface{}{"error": "generation failed midway"},
2189+
})
2190+
2191+
w.Close()
2192+
os.Stderr = oldStderr
2193+
buf := make([]byte, 4096)
2194+
n, _ := r.Read(buf)
2195+
output := string(buf[:n])
2196+
r.Close()
2197+
2198+
if err != nil {
2199+
t.Fatalf("unexpected error: %v", err)
2200+
}
2201+
if !next.called {
2202+
t.Fatal("expected guard to still print the structured error body via the wrapped formatter")
2203+
}
2204+
if !nonJSONErrorResponse {
2205+
t.Fatal("expected nonJSONErrorResponse to be set for a 2xx JSON error body")
2206+
}
2207+
if !strings.Contains(output, "application error") || !strings.Contains(output, "generation failed midway") {
2208+
t.Errorf("expected stderr to frame the application error, got:\n%s", output)
2209+
}
2210+
})
2211+
}
2212+
2213+
func TestJSONApplicationError(t *testing.T) {
2214+
tests := []struct {
2215+
name string
2216+
resp cli.Response
2217+
wantMsg string
2218+
wantOK bool
2219+
}{
2220+
{
2221+
name: "2xx with string error",
2222+
resp: cli.Response{Status: 200, Body: map[string]interface{}{"error": "boom"}},
2223+
wantMsg: "boom",
2224+
wantOK: true,
2225+
},
2226+
{
2227+
name: "2xx with object error and message",
2228+
resp: cli.Response{Status: 200, Body: map[string]interface{}{"error": map[string]interface{}{"message": "nested boom"}}},
2229+
wantMsg: "nested boom",
2230+
wantOK: true,
2231+
},
2232+
{
2233+
name: "2xx with object error without message",
2234+
resp: cli.Response{Status: 200, Body: map[string]interface{}{"error": map[string]interface{}{"code": 42}}},
2235+
wantMsg: "application error",
2236+
wantOK: true,
2237+
},
2238+
{
2239+
name: "2xx success body is ignored",
2240+
resp: cli.Response{Status: 200, Body: map[string]interface{}{"answer": "hi"}},
2241+
wantOK: false,
2242+
},
2243+
{
2244+
name: "2xx empty string error is ignored",
2245+
resp: cli.Response{Status: 200, Body: map[string]interface{}{"error": ""}},
2246+
wantOK: false,
2247+
},
2248+
{
2249+
name: "2xx null error is ignored",
2250+
resp: cli.Response{Status: 200, Body: map[string]interface{}{"error": nil}},
2251+
wantOK: false,
2252+
},
2253+
{
2254+
name: "non-2xx error is left to restish",
2255+
resp: cli.Response{Status: 400, Body: map[string]interface{}{"error": "bad request"}},
2256+
wantOK: false,
2257+
},
2258+
{
2259+
name: "non-object body is ignored",
2260+
resp: cli.Response{Status: 200, Body: "just a string"},
2261+
wantOK: false,
2262+
},
2263+
}
2264+
2265+
for _, tt := range tests {
2266+
t.Run(tt.name, func(t *testing.T) {
2267+
msg, ok := jsonApplicationError(tt.resp)
2268+
if ok != tt.wantOK {
2269+
t.Fatalf("jsonApplicationError() ok = %v, want %v", ok, tt.wantOK)
2270+
}
2271+
if ok && msg != tt.wantMsg {
2272+
t.Errorf("jsonApplicationError() msg = %q, want %q", msg, tt.wantMsg)
2273+
}
2274+
})
2275+
}
21732276
}

0 commit comments

Comments
 (0)