@@ -221,6 +221,7 @@ func main() {
221221func 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+
14211526func (t dciTableContentType ) Detect (contentType string ) bool { return false }
14221527
14231528func (t dciTableContentType ) Marshal (value interface {}) ([]byte , error ) {
0 commit comments