From ffdda343e8b397524de8793bdf8514e1bdd5cc04 Mon Sep 17 00:00:00 2001 From: Can Date: Sun, 7 Jun 2026 12:35:19 +0000 Subject: [PATCH] fix(api): surface backend error body in upload failures The CLI's /v2/upload, S3 PUT, and prepare calls all swallowed the response body and returned only "status code N". When the backend rejects an upload with a structured JSON error (e.g. REQUIRE_PAID_PLAN for exceeding plan upload size), users saw an opaque 400 with no clue about plan limits or how to fix it. Add util.FormatHTTPError that reads the body, prefers the JSON `error` / `code` fields when present, and falls back to the raw body or status code. Apply at the six call sites across pkg/api/service.go (existing-service deploy) and internal/cmd/upload/upload.go (new project upload). After: "failed to create upload session: Upload size 53048320 bytes exceeds your plan's limit of 52428800 bytes. Upgrade your plan to upload larger files. (code: REQUIRE_PAID_PLAN, status: 400)" Refs SEI-717. --- internal/cmd/upload/upload.go | 7 ++-- pkg/api/service.go | 7 ++-- pkg/util/http.go | 40 ++++++++++++++++++ pkg/util/http_test.go | 76 +++++++++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 pkg/util/http.go create mode 100644 pkg/util/http_test.go diff --git a/internal/cmd/upload/upload.go b/internal/cmd/upload/upload.go index 4614ebe..6ed02c6 100644 --- a/internal/cmd/upload/upload.go +++ b/internal/cmd/upload/upload.go @@ -17,6 +17,7 @@ import ( "github.com/zeabur/cli/internal/cmdutil" "github.com/zeabur/cli/internal/util" "github.com/zeabur/cli/pkg/constant" + pkgutil "github.com/zeabur/cli/pkg/util" ) type Options struct{} @@ -100,7 +101,7 @@ func UploadZipToService(ctx context.Context, zipBytes []byte) (string, error) { defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { - return "", fmt.Errorf("failed to create upload session: status code %d", resp.StatusCode) + return "", pkgutil.FormatHTTPError("failed to create upload session", resp) } var uploadSession struct { @@ -132,7 +133,7 @@ func UploadZipToService(ctx context.Context, zipBytes []byte) (string, error) { defer uploadResp.Body.Close() if uploadResp.StatusCode != http.StatusOK { - return "", fmt.Errorf("failed to upload to S3: status code %d", uploadResp.StatusCode) + return "", pkgutil.FormatHTTPError("failed to upload to S3", uploadResp) } // Step 4: Prepare upload for deployment @@ -164,7 +165,7 @@ func UploadZipToService(ctx context.Context, zipBytes []byte) (string, error) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("failed to prepare upload: status code %d", resp.StatusCode) + return "", pkgutil.FormatHTTPError("failed to prepare upload", resp) } return uploadSession.UploadID, nil diff --git a/pkg/api/service.go b/pkg/api/service.go index e126f60..06bf0ae 100644 --- a/pkg/api/service.go +++ b/pkg/api/service.go @@ -15,6 +15,7 @@ import ( "github.com/spf13/viper" "github.com/zeabur/cli/pkg/constant" "github.com/zeabur/cli/pkg/model" + "github.com/zeabur/cli/pkg/util" ) func (c *client) ListServices(ctx context.Context, projectID string, skip, limit int) (*model.Connection[model.Service], error) { @@ -392,7 +393,7 @@ func (c *client) UploadZipToService(ctx context.Context, projectID string, servi defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { - return nil, fmt.Errorf("failed to create upload session: status code %d", resp.StatusCode) + return nil, util.FormatHTTPError("failed to create upload session", resp) } var uploadSession struct { @@ -424,7 +425,7 @@ func (c *client) UploadZipToService(ctx context.Context, projectID string, servi defer uploadResp.Body.Close() if uploadResp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to upload to S3: status code %d", uploadResp.StatusCode) + return nil, util.FormatHTTPError("failed to upload to S3", uploadResp) } // Step 4: Prepare upload for deployment @@ -460,7 +461,7 @@ func (c *client) UploadZipToService(ctx context.Context, projectID string, servi defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to prepare upload: status code %d", resp.StatusCode) + return nil, util.FormatHTTPError("failed to prepare upload", resp) } return nil, nil diff --git a/pkg/util/http.go b/pkg/util/http.go new file mode 100644 index 0000000..520a4b5 --- /dev/null +++ b/pkg/util/http.go @@ -0,0 +1,40 @@ +package util + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +// FormatHTTPError reads a non-2xx HTTP response and returns an error that +// preserves whatever the server actually said. +// +// The Zeabur API returns JSON bodies of the form +// +// {"code": "REQUIRE_PAID_PLAN", "error": "Upload size ... exceeds ..."} +// +// for client-visible failures. When that shape is present, the message and +// (if available) code are surfaced. Otherwise the raw body is included so +// the user still has something to act on instead of a bare status code. +// +// action is a short verb phrase describing what failed, e.g. "create upload session". +func FormatHTTPError(action string, resp *http.Response) error { + body, _ := io.ReadAll(resp.Body) + + var errResp struct { + Code string `json:"code"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { + if errResp.Code != "" { + return fmt.Errorf("%s: %s (code: %s, status: %d)", action, errResp.Error, errResp.Code, resp.StatusCode) + } + return fmt.Errorf("%s: %s (status: %d)", action, errResp.Error, resp.StatusCode) + } + + if len(body) > 0 { + return fmt.Errorf("%s: status code %d, body: %s", action, resp.StatusCode, string(body)) + } + return fmt.Errorf("%s: status code %d", action, resp.StatusCode) +} diff --git a/pkg/util/http_test.go b/pkg/util/http_test.go new file mode 100644 index 0000000..e9ae823 --- /dev/null +++ b/pkg/util/http_test.go @@ -0,0 +1,76 @@ +package util_test + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/zeabur/cli/pkg/util" +) + +func TestFormatHTTPError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + statusCode int + body string + wantSubstr []string + }{ + { + name: "structured error with code (REQUIRE_PAID_PLAN)", + statusCode: 400, + body: `{"code":"REQUIRE_PAID_PLAN","error":"Upload size 53048320 bytes exceeds your plan's limit of 52428800 bytes.","limit":52428800,"content_length":53048320}`, + wantSubstr: []string{ + "do thing", + "Upload size 53048320 bytes exceeds", + "code: REQUIRE_PAID_PLAN", + "status: 400", + }, + }, + { + name: "structured error without code", + statusCode: 403, + body: `{"error":"forbidden"}`, + wantSubstr: []string{"do thing", "forbidden", "status: 403"}, + }, + { + name: "non-JSON body falls back to raw body", + statusCode: 502, + body: "Bad Gateway", + wantSubstr: []string{"do thing", "status code 502", "Bad Gateway"}, + }, + { + name: "empty body falls back to status code only", + statusCode: 504, + body: "", + wantSubstr: []string{"do thing", "status code 504"}, + }, + { + name: "JSON without error field falls back to raw body", + statusCode: 400, + body: `{"foo":"bar"}`, + wantSubstr: []string{"status code 400", `{"foo":"bar"}`}, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + resp := &http.Response{ + StatusCode: tc.statusCode, + Body: io.NopCloser(strings.NewReader(tc.body)), + } + + err := util.FormatHTTPError("do thing", resp) + assert.Error(t, err) + for _, s := range tc.wantSubstr { + assert.Contains(t, err.Error(), s) + } + }) + } +}