From 588b5c222d02bd487270dd32786d57a4575eee11 Mon Sep 17 00:00:00 2001 From: tytv2 Date: Sat, 13 Jun 2026 21:58:30 +0700 Subject: [PATCH] fix(client): surface nested error bodies and implement --debug logging The VKS API returns errors as {"error":{"message":...}} (nested object). formatError only handled string fields, so users saw a bare "API error (HTTP 400 Bad Request)" with no detail. Add a raw-body fallback. Also wire up the previously-unused --debug flag to log request method/URL/body and response status/body to stderr. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../next-release/bugfix-client-4qrbt6n2.json | 5 +++++ go/internal/client/client.go | 20 +++++++++++++++++++ go/internal/client/client_test.go | 19 ++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 .changes/next-release/bugfix-client-4qrbt6n2.json diff --git a/.changes/next-release/bugfix-client-4qrbt6n2.json b/.changes/next-release/bugfix-client-4qrbt6n2.json new file mode 100644 index 0000000..4e2a7b1 --- /dev/null +++ b/.changes/next-release/bugfix-client-4qrbt6n2.json @@ -0,0 +1,5 @@ +{ + "type": "bugfix", + "category": "client", + "description": "Surface nested API error bodies ({\"error\":{\"message\":...}}) instead of a bare HTTP status, and make the --debug flag log request/response" +} diff --git a/go/internal/client/client.go b/go/internal/client/client.go index 8c6433e..8e553e6 100644 --- a/go/internal/client/client.go +++ b/go/internal/client/client.go @@ -8,6 +8,8 @@ import ( "io" "net/http" "net/url" + "os" + "strings" "time" "github.com/vngcloud/greennode-cli/internal/auth" @@ -191,6 +193,14 @@ func (c *GreenodeClient) requestRaw(method, path string, params map[string]strin req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") + if c.debug { + fmt.Fprintf(os.Stderr, "[debug] %s %s\n", method, fullURL) + if body != nil { + jsonBody, _ := json.Marshal(body) + fmt.Fprintf(os.Stderr, "[debug] request body: %s\n", string(jsonBody)) + } + } + resp, err := c.httpClient.Do(req) if err != nil { lastErr = err @@ -205,6 +215,10 @@ func (c *GreenodeClient) requestRaw(method, path string, params map[string]strin respBody, _ := io.ReadAll(resp.Body) resp.Body.Close() + if c.debug { + fmt.Fprintf(os.Stderr, "[debug] response %d: %s\n", resp.StatusCode, string(respBody)) + } + // 401 — refresh token and retry once if resp.StatusCode == http.StatusUnauthorized { token, err = c.tokenManager.RefreshToken() @@ -266,6 +280,12 @@ func formatError(statusCode int, body []byte) string { } } } + // Fallback: the error payload didn't use a known string field (e.g. the + // VKS API returns {"error": {...}} as a nested object). Surface the raw + // JSON body so the server's message isn't silently dropped. + if detail == "" && len(body) > 0 { + detail = strings.TrimSpace(string(body)) + } } else { detail = string(body) } diff --git a/go/internal/client/client_test.go b/go/internal/client/client_test.go index 0df21a0..7b65225 100644 --- a/go/internal/client/client_test.go +++ b/go/internal/client/client_test.go @@ -4,6 +4,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -39,3 +40,21 @@ func TestPatchSendsPatchMethodAndBody(t *testing.T) { t.Errorf("body = %q, want enableAutoHealing payload", gotBody) } } + +func TestFormatErrorSurfacesNestedErrorObject(t *testing.T) { + // VKS returns errors as {"error": {"message": ...}} — a nested object, not a + // string. The detail must still reach the user instead of being dropped. + body := []byte(`{"error":{"message":"KubeConfig can only be requested when the cluster is ACTIVE."}}`) + got := formatError(http.StatusBadRequest, body) + if !strings.Contains(got, "cluster is ACTIVE") { + t.Errorf("formatError = %q, want it to contain the nested error message", got) + } +} + +func TestFormatErrorUsesPlainStringMessage(t *testing.T) { + body := []byte(`{"message":"boom"}`) + got := formatError(http.StatusBadRequest, body) + if !strings.Contains(got, "boom") { + t.Errorf("formatError = %q, want it to contain %q", got, "boom") + } +}