Some checks failed
CI / build-and-test (push) Has been cancelled
Release / build (arm64, darwin) (push) Has been cancelled
Release / build (amd64, windows) (push) Has been cancelled
Release / build (arm64, linux) (push) Has been cancelled
Release / build (amd64, darwin) (push) Has been cancelled
Release / build (amd64, linux) (push) Has been cancelled
Release / release (push) Has been cancelled
Adds package-level documentation comments across cmd and internal packages. Marks unused function parameters with underscore prefix to satisfy linter requirements. Replaces if-else chains with switch statements for better readability. Explicitly ignores os.Setenv return value where error handling is not needed.
147 lines
3.9 KiB
Go
147 lines
3.9 KiB
Go
// Copyright 2026 MarketAlly. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
// Package artifact provides utilities for handling artifact uploads.
|
|
package artifact
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
// UploadHelper handles reliable file uploads with retry logic
|
|
type UploadHelper struct {
|
|
MaxRetries int
|
|
RetryDelay time.Duration
|
|
ChunkSize int64
|
|
ConnectTimeout time.Duration
|
|
MaxTimeout time.Duration
|
|
}
|
|
|
|
// NewUploadHelper creates a new upload helper with sensible defaults
|
|
func NewUploadHelper() *UploadHelper {
|
|
return &UploadHelper{
|
|
MaxRetries: 5,
|
|
RetryDelay: 10 * time.Second,
|
|
ChunkSize: 10 * 1024 * 1024, // 10MB
|
|
ConnectTimeout: 120 * time.Second,
|
|
MaxTimeout: 3600 * time.Second,
|
|
}
|
|
}
|
|
|
|
// UploadWithRetry uploads a file with automatic retry on failure
|
|
func (u *UploadHelper) UploadWithRetry(url, token, filepath string) error {
|
|
client := &http.Client{
|
|
Timeout: u.MaxTimeout,
|
|
Transport: &http.Transport{
|
|
MaxIdleConns: 10,
|
|
MaxIdleConnsPerHost: 5,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
DisableKeepAlives: false, // Keep connections alive
|
|
ForceAttemptHTTP2: false, // Use HTTP/1.1 for large uploads
|
|
},
|
|
}
|
|
|
|
var lastErr error
|
|
for attempt := 0; attempt < u.MaxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
delay := u.RetryDelay * time.Duration(attempt)
|
|
log.Infof("Upload attempt %d/%d, waiting %v before retry...", attempt+1, u.MaxRetries, delay)
|
|
time.Sleep(delay)
|
|
}
|
|
|
|
// Pre-resolve DNS / warm connection
|
|
if err := u.prewarmConnection(url); err != nil {
|
|
lastErr = fmt.Errorf("connection prewarm failed: %w", err)
|
|
log.Warnf("Prewarm failed: %v", err)
|
|
continue
|
|
}
|
|
|
|
// Attempt upload
|
|
if err := u.doUpload(client, url, token, filepath); err != nil {
|
|
lastErr = err
|
|
log.Warnf("Upload attempt %d failed: %v", attempt+1, err)
|
|
continue
|
|
}
|
|
|
|
log.Infof("Upload succeeded on attempt %d", attempt+1)
|
|
return nil // Success
|
|
}
|
|
|
|
return fmt.Errorf("upload failed after %d attempts: %w", u.MaxRetries, lastErr)
|
|
}
|
|
|
|
// prewarmConnection establishes a connection to help with DNS and TCP setup
|
|
func (u *UploadHelper) prewarmConnection(url string) error {
|
|
req, err := http.NewRequest("HEAD", url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_ = resp.Body.Close()
|
|
return nil
|
|
}
|
|
|
|
// doUpload performs the actual file upload
|
|
func (u *UploadHelper) doUpload(client *http.Client, url, token, filepath string) error {
|
|
file, err := os.Open(filepath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open file: %w", err)
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
stat, err := file.Stat()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to stat file: %w", err)
|
|
}
|
|
|
|
log.Infof("Uploading %s (%d bytes) to %s", filepath, stat.Size(), url)
|
|
|
|
// Create multipart form
|
|
body := &bytes.Buffer{}
|
|
writer := multipart.NewWriter(body)
|
|
part, err := writer.CreateFormFile("attachment", stat.Name())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create form file: %w", err)
|
|
}
|
|
|
|
if _, err := io.Copy(part, file); err != nil {
|
|
return fmt.Errorf("failed to copy file to form: %w", err)
|
|
}
|
|
_ = writer.Close()
|
|
|
|
req, err := http.NewRequest("POST", url, body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
req.Header.Set("Connection", "keep-alive")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("upload request failed: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
log.Infof("Upload completed successfully, status: %d", resp.StatusCode)
|
|
return nil
|
|
}
|