cli/pkg/cmd/release/shared/upload.go
Mislav Marohnić 40da8f9c69 Allow retrying HTTP/2 uploads for release assets
The `Request.GetBody` func allows the retry mechanism to reopen the file
that's being uploaded as the request body in case the body of the
previous request has already started to be read.

Hopefully fixes the error:

    http2: Transport: cannot retry err [stream error: stream ID 1; REFUSED_STREAM]
    after Request.Body was written; define Request.GetBody to avoid this error

Ref. d523dce5a7/http2/transport.go (L554)
2021-03-17 15:55:22 +01:00

218 lines
4.1 KiB
Go

package shared
import (
"encoding/json"
"errors"
"io"
"io/ioutil"
"mime"
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
"github.com/cli/cli/api"
)
type AssetForUpload struct {
Name string
Label string
Size int64
MIMEType string
Open func() (io.ReadCloser, error)
ExistingURL string
}
func AssetsFromArgs(args []string) (assets []*AssetForUpload, err error) {
for _, arg := range args {
var label string
fn := arg
if idx := strings.IndexRune(arg, '#'); idx > 0 {
fn = arg[0:idx]
label = arg[idx+1:]
}
var fi os.FileInfo
fi, err = os.Stat(fn)
if err != nil {
return
}
assets = append(assets, &AssetForUpload{
Open: func() (io.ReadCloser, error) {
return os.Open(fn)
},
Size: fi.Size(),
Name: fi.Name(),
Label: label,
MIMEType: typeForFilename(fi.Name()),
})
}
return
}
func typeForFilename(fn string) string {
ext := fileExt(fn)
switch ext {
case ".zip":
return "application/zip"
case ".js":
return "application/javascript"
case ".tar":
return "application/x-tar"
case ".tgz", ".tar.gz":
return "application/x-gtar"
case ".bz2":
return "application/x-bzip2"
case ".dmg":
return "application/x-apple-diskimage"
case ".rpm":
return "application/x-rpm"
case ".deb":
return "application/x-debian-package"
}
t := mime.TypeByExtension(ext)
if t == "" {
return "application/octet-stream"
}
return t
}
func fileExt(fn string) string {
fn = strings.ToLower(fn)
if strings.HasSuffix(fn, ".tar.gz") {
return ".tar.gz"
}
return path.Ext(fn)
}
func ConcurrentUpload(httpClient *http.Client, uploadURL string, numWorkers int, assets []*AssetForUpload) error {
if numWorkers == 0 {
return errors.New("the number of concurrent workers needs to be greater than 0")
}
jobs := make(chan AssetForUpload, len(assets))
results := make(chan error, len(assets))
if len(assets) < numWorkers {
numWorkers = len(assets)
}
for w := 1; w <= numWorkers; w++ {
go func() {
for a := range jobs {
results <- uploadWithDelete(httpClient, uploadURL, a)
}
}()
}
for _, a := range assets {
jobs <- *a
}
close(jobs)
var uploadError error
for i := 0; i < len(assets); i++ {
if err := <-results; err != nil {
uploadError = err
}
}
return uploadError
}
const maxRetries = 3
func uploadWithDelete(httpClient *http.Client, uploadURL string, a AssetForUpload) error {
if a.ExistingURL != "" {
err := deleteAsset(httpClient, a.ExistingURL)
if err != nil {
return err
}
}
retries := 0
for {
var httpError api.HTTPError
_, err := uploadAsset(httpClient, uploadURL, a)
// retry upload several times upon receiving HTTP 5xx
if err == nil || !errors.As(err, &httpError) || httpError.StatusCode < 500 || retries < maxRetries {
return err
}
retries++
time.Sleep(time.Duration(retries) * time.Second)
}
}
func uploadAsset(httpClient *http.Client, uploadURL string, asset AssetForUpload) (*ReleaseAsset, error) {
u, err := url.Parse(uploadURL)
if err != nil {
return nil, err
}
params := u.Query()
params.Set("name", asset.Name)
params.Set("label", asset.Label)
u.RawQuery = params.Encode()
f, err := asset.Open()
if err != nil {
return nil, err
}
defer f.Close()
req, err := http.NewRequest("POST", u.String(), f)
if err != nil {
return nil, err
}
req.ContentLength = asset.Size
req.Header.Set("Content-Type", asset.MIMEType)
req.GetBody = asset.Open
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var newAsset ReleaseAsset
err = json.Unmarshal(b, &newAsset)
if err != nil {
return nil, err
}
return &newAsset, nil
}
func deleteAsset(httpClient *http.Client, assetURL string) error {
req, err := http.NewRequest("DELETE", assetURL, nil)
if err != nil {
return err
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return api.HandleHTTPError(resp)
}
return nil
}