diff --git a/internal/cmd/upload/upload.go b/internal/cmd/upload/upload.go index 6ed02c6..8f6d4d1 100644 --- a/internal/cmd/upload/upload.go +++ b/internal/cmd/upload/upload.go @@ -118,7 +118,12 @@ func UploadZipToService(ctx context.Context, zipBytes []byte) (string, error) { } // Step 3: Upload file to S3 - uploadReq, err := http.NewRequestWithContext(ctx, uploadSession.PresignMethod, uploadSession.PresignURL, bytes.NewReader(zipBytes)) + // The S3 PUT carries the whole zip, so the shared 30s client would cap + // uploads at ~14 Mbps. Give it its own deadline that scales with size. + uploadCtx, cancelUpload := context.WithTimeout(ctx, pkgutil.UploadTimeout(int64(len(zipBytes)))) + defer cancelUpload() + + uploadReq, err := http.NewRequestWithContext(uploadCtx, uploadSession.PresignMethod, uploadSession.PresignURL, bytes.NewReader(zipBytes)) if err != nil { return "", fmt.Errorf("failed to create S3 upload request: %w", err) } @@ -126,7 +131,7 @@ func UploadZipToService(ctx context.Context, zipBytes []byte) (string, error) { uploadReq.Header.Set("Content-Type", uploadSession.PresignHeader.ContentType) uploadReq.Header.Set("Content-Length", strconv.FormatInt(int64(len(zipBytes)), 10)) - uploadResp, err := client.Do(uploadReq) + uploadResp, err := (&http.Client{}).Do(uploadReq) if err != nil { return "", fmt.Errorf("failed to upload to S3: %w", err) } diff --git a/pkg/api/service.go b/pkg/api/service.go index 06bf0ae..2f6dfe5 100644 --- a/pkg/api/service.go +++ b/pkg/api/service.go @@ -410,7 +410,12 @@ func (c *client) UploadZipToService(ctx context.Context, projectID string, servi } // Step 3: Upload file to S3 - uploadReq, err := http.NewRequestWithContext(ctx, uploadSession.PresignMethod, uploadSession.PresignURL, bytes.NewReader(zipBytes)) + // The S3 PUT carries the whole zip, so the shared 30s client would cap + // uploads at ~14 Mbps. Give it its own deadline that scales with size. + uploadCtx, cancelUpload := context.WithTimeout(ctx, util.UploadTimeout(int64(len(zipBytes)))) + defer cancelUpload() + + uploadReq, err := http.NewRequestWithContext(uploadCtx, uploadSession.PresignMethod, uploadSession.PresignURL, bytes.NewReader(zipBytes)) if err != nil { return nil, fmt.Errorf("failed to create S3 upload request: %w", err) } @@ -418,7 +423,7 @@ func (c *client) UploadZipToService(ctx context.Context, projectID string, servi uploadReq.Header.Set("Content-Type", uploadSession.PresignHeader.ContentType) uploadReq.Header.Set("Content-Length", strconv.FormatInt(int64(len(zipBytes)), 10)) - uploadResp, err := client.Do(uploadReq) + uploadResp, err := (&http.Client{}).Do(uploadReq) if err != nil { return nil, fmt.Errorf("failed to upload to S3: %w", err) } diff --git a/pkg/util/http.go b/pkg/util/http.go index 520a4b5..173ccb5 100644 --- a/pkg/util/http.go +++ b/pkg/util/http.go @@ -5,8 +5,20 @@ import ( "fmt" "io" "net/http" + "time" ) +// UploadTimeout returns how long an upload of contentLength bytes is allowed +// to take: a 2-minute floor plus one second per 256 KiB of payload. +// +// http.Client.Timeout covers the entire request including body transfer, so a +// fixed timeout makes large uploads fail on slow links no matter how patient +// the user is. The 256 KiB/s floor is deliberately conservative (~2 Mbps); +// a 50 MiB zip gets ~5.3 minutes. +func UploadTimeout(contentLength int64) time.Duration { + return 2*time.Minute + time.Duration(contentLength/(256*1024))*time.Second +} + // FormatHTTPError reads a non-2xx HTTP response and returns an error that // preserves whatever the server actually said. // diff --git a/pkg/util/http_test.go b/pkg/util/http_test.go index e9ae823..8e79c39 100644 --- a/pkg/util/http_test.go +++ b/pkg/util/http_test.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/zeabur/cli/pkg/util" @@ -74,3 +75,26 @@ func TestFormatHTTPError(t *testing.T) { }) } } + +func TestUploadTimeout(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + size int64 + want time.Duration + }{ + {"zero size gets the 2min floor", 0, 2 * time.Minute}, + {"tiny payload still gets the floor", 1024, 2 * time.Minute}, + {"1 MiB adds 4s over the floor", 1 << 20, 2*time.Minute + 4*time.Second}, + {"50 MiB scales to ~5.3 min", 50 << 20, 2*time.Minute + 200*time.Second}, + {"1 GiB scales to ~70 min", 1 << 30, 2*time.Minute + 4096*time.Second}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, util.UploadTimeout(tc.size)) + }) + } +}