Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions internal/cmd/upload/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions pkg/api/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions pkg/util/http.go
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
canyugs marked this conversation as resolved.

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)
Comment thread
canyugs marked this conversation as resolved.
}
76 changes: 76 additions & 0 deletions pkg/util/http_test.go
Original file line number Diff line number Diff line change
@@ -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: "<html>Bad Gateway</html>",
wantSubstr: []string{"do thing", "status code 502", "<html>Bad Gateway</html>"},
},
{
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)
}
})
}
}