Retry transient uploads and continue backups
All checks were successful
Build / Test and build (push) Successful in 5m53s

This commit is contained in:
2026-08-15 15:32:43 +08:00
parent 977fcefc2f
commit c602698ce9
7 changed files with 175 additions and 28 deletions

View File

@@ -89,7 +89,10 @@ OOB 模式使用 `redirect_uri=oob`,无需在本机监听端口。
# 递归上传目录内容,保留相对目录结构 # 递归上传目录内容,保留相对目录结构
./bdrclone upload ~/ /备份/mac/ ./bdrclone upload ~/ /备份/mac/
# 排除受保护目录和缓存;--exclude 可重复,支持 ** # 排除受保护目录和缓存;--exclude 可重复,支持 **
./bdrclone upload ~/ /备份/mac/ --exclude .Trash --exclude 'Library/Caches/**' ./bdrclone upload ~/ /备份/mac/ \
--exclude .Trash \
--exclude 'Library/Caches/**' \
--exclude '.cache/codex-runtimes/**'
./bdrclone mkdir /备份/新目录 ./bdrclone mkdir /备份/新目录
./bdrclone mv /备份/a.txt /备份/b.txt ./bdrclone mv /备份/a.txt /备份/b.txt
./bdrclone cp /备份/b.txt /副本/b.txt ./bdrclone cp /备份/b.txt /副本/b.txt
@@ -127,6 +130,7 @@ curl http://127.0.0.1:8080/文档/report.pdf -o report.pdf
- 百度网盘官方接口不允许创建空文件;挂载下的空文件在 flush 时会失败。 - 百度网盘官方接口不允许创建空文件;挂载下的空文件在 flush 时会失败。
- 目录上传会创建远端空目录,但会跳过无法读取的路径、符号链接、特殊文件及百度接口无法表示的空文件,并逐项提示。 - 目录上传会创建远端空目录,但会跳过无法读取的路径、符号链接、特殊文件及百度接口无法表示的空文件,并逐项提示。
- `upload --exclude` 使用相对于源目录的 glob;无 `/` 的规则匹配任意层级的文件名,`**` 可跨目录匹配。 - `upload --exclude` 使用相对于源目录的 glob;无 `/` 的规则匹配任意层级的文件名,`**` 可跨目录匹配。
- 分片上传遇到临时网络或网关错误会退避重试 6 次;目录中单个文件最终失败时会记录错误并继续,结束时返回失败状态。
- 大文件下载必须使用 `User-Agent: pan.baidu.com`,客户端已统一设置。 - 大文件下载必须使用 `User-Agent: pan.baidu.com`,客户端已统一设置。
- 分片大小由会员等级决定:普通用户 4 MiB、会员 16 MiB、超级会员 32 MiB;百度限制分片数, - 分片大小由会员等级决定:普通用户 4 MiB、会员 16 MiB、超级会员 32 MiB;百度限制分片数,
因此不同等级的单文件上限不同。 因此不同等级的单文件上限不同。

View File

@@ -276,12 +276,13 @@ func (a *application) uploadCommand() *cobra.Command {
} }
stderr := cmd.ErrOrStderr() stderr := cmd.ErrOrStderr()
lineOpen := false lineOpen := false
summary, err := uploadLocalPath(cmd.Context(), client, args[0], args[1], uploadPathOptions{Excludes: excludes, Progress: func(localPath, remotePath string, done, total int64) { summary, err := uploadLocalPath(cmd.Context(), client, args[0], args[1], uploadPathOptions{Excludes: excludes, Progress: func(_, remotePath string, done, total int64) {
displayPath := uploadProgressPath(args[1], remotePath)
percent := int64(0) percent := int64(0)
if total > 0 { if total > 0 {
percent = done * 100 / total percent = done * 100 / total
} }
fmt.Fprintf(stderr, "\r上传 %s -> %s: %d/%d bytes (%d%%)", localPath, remotePath, done, total, percent) fmt.Fprintf(stderr, "\r上传 %s: %d/%d bytes (%d%%)", displayPath, done, total, percent)
lineOpen = done < total lineOpen = done < total
if !lineOpen { if !lineOpen {
fmt.Fprintln(stderr) fmt.Fprintln(stderr)
@@ -292,15 +293,20 @@ func (a *application) uploadCommand() *cobra.Command {
lineOpen = false lineOpen = false
} }
fmt.Fprintf(stderr, "跳过 %s: %s\n", localPath, reason) fmt.Fprintf(stderr, "跳过 %s: %s\n", localPath, reason)
}, Failed: func(_, _ string, uploadErr error) {
if lineOpen {
fmt.Fprintln(stderr)
lineOpen = false
}
fmt.Fprintf(stderr, "失败: %v\n", uploadErr)
}}) }})
if lineOpen { if lineOpen {
fmt.Fprintln(stderr) fmt.Fprintln(stderr)
} }
if err != nil { if summary.Directories > 0 || summary.Files > 0 || summary.Skipped > 0 || summary.Failed > 0 {
return err fmt.Fprintf(stderr, "完成:成功上传 %d 个文件(%s),处理 %d 个目录,跳过 %d 项,失败 %d 个文件\n", summary.Files, formatBytes(summary.Bytes), summary.Directories, summary.Skipped, summary.Failed)
} }
fmt.Fprintf(stderr, "完成:上传 %d 个文件(%s),处理 %d 个目录,跳过 %d 项\n", summary.Files, formatBytes(summary.Bytes), summary.Directories, summary.Skipped) return err
return nil
}} }}
cmd.Flags().StringArrayVar(&excludes, "exclude", nil, "排除源目录中的 glob(可重复,支持 **)") cmd.Flags().StringArrayVar(&excludes, "exclude", nil, "排除源目录中的 glob(可重复,支持 **)")
return cmd return cmd

View File

@@ -23,16 +23,19 @@ type uploadPathSummary struct {
Files int Files int
Directories int Directories int
Skipped int Skipped int
Failed int
Bytes int64 Bytes int64
} }
type uploadPathProgress func(localPath, remotePath string, uploaded, total int64) type uploadPathProgress func(localPath, remotePath string, uploaded, total int64)
type uploadPathSkipped func(localPath, reason string) type uploadPathSkipped func(localPath, reason string)
type uploadPathFailed func(localPath, remotePath string, err error)
type uploadPathOptions struct { type uploadPathOptions struct {
Excludes []string Excludes []string
Progress uploadPathProgress Progress uploadPathProgress
Skipped uploadPathSkipped Skipped uploadPathSkipped
Failed uploadPathFailed
} }
type uploadWalkDir func(string, fs.WalkDirFunc) error type uploadWalkDir func(string, fs.WalkDirFunc) error
@@ -77,6 +80,7 @@ func uploadLocalDirectory(ctx context.Context, client uploadPathClient, root, re
return uploadPathSummary{}, err return uploadPathSummary{}, err
} }
summary := uploadPathSummary{Directories: 1} summary := uploadPathSummary{Directories: 1}
var firstUploadErr error
err := walk(root, func(localPath string, entry fs.DirEntry, walkErr error) error { err := walk(root, func(localPath string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil { if walkErr != nil {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
@@ -131,11 +135,23 @@ func uploadLocalDirectory(ctx context.Context, client uploadPathClient, root, re
reportUploadSkip(&summary, options, localPath, "百度开放 API 不支持空文件") reportUploadSkip(&summary, options, localPath, "百度开放 API 不支持空文件")
return nil return nil
} }
return uploadRegularFile(ctx, client, localPath, remotePath, info, options.Progress, &summary) if err := uploadRegularFile(ctx, client, localPath, remotePath, info, options.Progress, &summary); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
if firstUploadErr == nil {
firstUploadErr = err
}
reportUploadFailure(&summary, options, localPath, remotePath, err)
}
return nil
}) })
if err != nil { if err != nil {
return summary, err return summary, err
} }
if summary.Failed > 0 {
return summary, fmt.Errorf("%d files failed to upload; first failure: %w", summary.Failed, firstUploadErr)
}
return summary, nil return summary, nil
} }
@@ -146,6 +162,13 @@ func reportUploadSkip(summary *uploadPathSummary, options uploadPathOptions, loc
} }
} }
func reportUploadFailure(summary *uploadPathSummary, options uploadPathOptions, localPath, remotePath string, err error) {
summary.Failed++
if options.Failed != nil {
options.Failed(localPath, remotePath, err)
}
}
func uploadRegularFile(ctx context.Context, client uploadPathClient, localPath, remotePath string, info fs.FileInfo, progress uploadPathProgress, summary *uploadPathSummary) error { func uploadRegularFile(ctx context.Context, client uploadPathClient, localPath, remotePath string, info fs.FileInfo, progress uploadPathProgress, summary *uploadPathSummary) error {
completeReported := false completeReported := false
if progress != nil && info.Size() > 0 { if progress != nil && info.Size() > 0 {
@@ -172,6 +195,14 @@ func cleanUploadRemotePath(name string) string {
return pathpkg.Clean("/" + strings.TrimPrefix(name, "/")) return pathpkg.Clean("/" + strings.TrimPrefix(name, "/"))
} }
func uploadProgressPath(remoteDestination, remotePath string) string {
root := cleanUploadRemotePath(remoteDestination)
if relative := strings.TrimPrefix(remotePath, root+"/"); relative != remotePath {
return relative
}
return pathpkg.Base(remotePath)
}
func normalizeUploadExcludes(patterns []string) ([]string, error) { func normalizeUploadExcludes(patterns []string) ([]string, error) {
result := make([]string, 0, len(patterns)) result := make([]string, 0, len(patterns))
for _, pattern := range patterns { for _, pattern := range patterns {

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
@@ -16,6 +17,7 @@ import (
type recordingUploadClient struct { type recordingUploadClient struct {
directories []string directories []string
files map[string][]byte files map[string][]byte
failures map[string]error
} }
func (c *recordingUploadClient) MkdirAll(_ context.Context, remotePath string) error { func (c *recordingUploadClient) MkdirAll(_ context.Context, remotePath string) error {
@@ -36,6 +38,9 @@ func (c *recordingUploadClient) UploadFile(_ context.Context, localPath, remoteP
if c.files == nil { if c.files == nil {
c.files = make(map[string][]byte) c.files = make(map[string][]byte)
} }
if err := c.failures[remotePath]; err != nil {
return baidu.File{}, err
}
c.files[remotePath] = content c.files[remotePath] = content
if progress != nil { if progress != nil {
progress(int64(len(content)), int64(len(content))) progress(int64(len(content)), int64(len(content)))
@@ -197,6 +202,35 @@ func TestUploadLocalDirectoryContinuesAfterReadError(t *testing.T) {
} }
} }
func TestUploadLocalDirectoryContinuesAfterUploadFailure(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "a-fails.txt"), []byte("failed"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "b-succeeds.txt"), []byte("success"), 0o600); err != nil {
t.Fatal(err)
}
client := &recordingUploadClient{failures: map[string]error{
"/backup/mac/a-fails.txt": errors.New("gateway timeout"),
}}
var failed []string
summary, err := uploadLocalPath(context.Background(), client, root, "/backup/mac", uploadPathOptions{Failed: func(localPath, remotePath string, err error) {
failed = append(failed, filepath.Base(localPath)+":"+remotePath+":"+err.Error())
}})
if err == nil || !strings.Contains(err.Error(), "1 files failed to upload") {
t.Fatalf("error = %v", err)
}
if summary.Files != 1 || summary.Failed != 1 || summary.Bytes != 7 {
t.Fatalf("summary = %+v", summary)
}
if len(failed) != 1 || !strings.Contains(failed[0], "a-fails.txt:/backup/mac/a-fails.txt:") || !strings.Contains(failed[0], "gateway timeout") {
t.Fatalf("failed = %q", failed)
}
if !bytes.Equal(client.files["/backup/mac/b-succeeds.txt"], []byte("success")) {
t.Fatalf("b-succeeds.txt was not uploaded: %v", client.files)
}
}
func TestUploadExcludePatternMatching(t *testing.T) { func TestUploadExcludePatternMatching(t *testing.T) {
patterns, err := normalizeUploadExcludes([]string{".Trash/", "Library/Caches/**", "**/*.tmp"}) patterns, err := normalizeUploadExcludes([]string{".Trash/", "Library/Caches/**", "**/*.tmp"})
if err != nil { if err != nil {
@@ -220,3 +254,12 @@ func TestUploadExcludePatternMatching(t *testing.T) {
t.Fatal("invalid exclude pattern was accepted") t.Fatal("invalid exclude pattern was accepted")
} }
} }
func TestUploadProgressPath(t *testing.T) {
if got := uploadProgressPath("/backup/mac/", "/backup/mac/Library/file.txt"); got != "Library/file.txt" {
t.Fatalf("progress path = %q", got)
}
if got := uploadProgressPath("/backup/file.txt", "/backup/file.txt"); got != "file.txt" {
t.Fatalf("single-file progress path = %q", got)
}
}

View File

@@ -25,16 +25,17 @@ const (
) )
type Client struct { type Client struct {
httpClient *http.Client httpClient *http.Client
apiBase string apiBase string
oauthBase string oauthBase string
uploadBase string uploadBase string
userAgent string userAgent string
root string root string
uploadParts int uploadParts int
partSize int64 partSize int64
downloadMu sync.Mutex uploadRetryDelay func(int) time.Duration
downloadURL map[int64]cachedDownloadURL downloadMu sync.Mutex
downloadURL map[int64]cachedDownloadURL
mu sync.Mutex mu sync.Mutex
clientID string clientID string
@@ -75,8 +76,9 @@ func New(cfg *config.Config, opts ...Option) *Client {
apiBase: defaultAPIBase, oauthBase: defaultOAuthBase, uploadBase: defaultUploadBase, apiBase: defaultAPIBase, oauthBase: defaultOAuthBase, uploadBase: defaultUploadBase,
userAgent: cfg.UserAgent, root: cfg.Root, userAgent: cfg.UserAgent, root: cfg.Root,
uploadParts: cfg.UploadParts, partSize: cfg.PartSize, uploadParts: cfg.UploadParts, partSize: cfg.PartSize,
downloadURL: make(map[int64]cachedDownloadURL), uploadRetryDelay: defaultUploadRetryDelay,
clientID: cfg.ClientID, secret: cfg.ClientSecret, downloadURL: make(map[int64]cachedDownloadURL),
clientID: cfg.ClientID, secret: cfg.ClientSecret,
accessToken: cfg.AccessToken, refresh: cfg.RefreshToken, expiresAt: cfg.ExpiresAt, accessToken: cfg.AccessToken, refresh: cfg.RefreshToken, expiresAt: cfg.ExpiresAt,
} }
for _, opt := range opts { for _, opt := range opts {

View File

@@ -12,6 +12,7 @@ import (
"path" "path"
"reflect" "reflect"
"strconv" "strconv"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -205,6 +206,60 @@ func TestUploadMultipartFlow(t *testing.T) {
} }
} }
func TestUploadPartRetriesGatewayTimeout(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "retry-upload-*")
if err != nil {
t.Fatal(err)
}
defer file.Close()
content := []byte("retry payload")
if _, err := file.Write(content); err != nil {
t.Fatal(err)
}
attempts := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/rest/2.0/pcs/superfile2" {
http.NotFound(w, r)
return
}
attempts++
_, _ = io.Copy(io.Discard, r.Body)
if attempts < 4 {
http.Error(w, "gateway timeout", http.StatusGatewayTimeout)
return
}
fmt.Fprint(w, `{"md5":"ok"}`)
}))
defer server.Close()
client := New(testConfig("token"), WithEndpoints(server.URL, server.URL, server.URL))
client.uploadRetryDelay = func(int) time.Duration { return 0 }
if err := client.uploadPartWithRetry(context.Background(), file, "/apps/bdrclone/retry.bin", "upload-1", 0, 0, int64(len(content))); err != nil {
t.Fatal(err)
}
if attempts != 4 {
t.Fatalf("attempts = %d, want 4", attempts)
}
failureAttempts := 0
failureServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
failureAttempts++
_, _ = io.Copy(io.Discard, r.Body)
http.Error(w, "gateway timeout", http.StatusGatewayTimeout)
}))
defer failureServer.Close()
failureClient := New(testConfig("token"), WithEndpoints(failureServer.URL, failureServer.URL, failureServer.URL))
failureClient.uploadRetryDelay = func(int) time.Duration { return 0 }
err = failureClient.uploadPartWithRetry(context.Background(), file, "/apps/bdrclone/retry.bin", "upload-2", 0, 0, int64(len(content)))
if err == nil || !strings.Contains(err.Error(), "after 6 attempts") {
t.Fatalf("error = %v", err)
}
if failureAttempts != maxUploadAttempts {
t.Fatalf("attempts = %d, want %d", failureAttempts, maxUploadAttempts)
}
}
func TestMkdirAllCreatesMissingRemoteDirectories(t *testing.T) { func TestMkdirAllCreatesMissingRemoteDirectories(t *testing.T) {
directories := map[string]bool{"/apps/bdrclone": true} directories := map[string]bool{"/apps/bdrclone": true}
var created []string var created []string

View File

@@ -21,10 +21,11 @@ import (
) )
const ( const (
defaultPartSize = int64(4 << 20) defaultPartSize = int64(4 << 20)
vipPartSize = int64(16 << 20) vipPartSize = int64(16 << 20)
svipPartSize = int64(32 << 20) svipPartSize = int64(32 << 20)
maxPartCount = 2048 maxPartCount = 2048
maxUploadAttempts = 6
) )
type UploadProgress func(uploaded, total int64) type UploadProgress func(uploaded, total int64)
@@ -247,7 +248,7 @@ func (c *Client) uploadPartsParallel(ctx context.Context, file *os.File, remote,
func (c *Client) uploadPartWithRetry(ctx context.Context, file *os.File, remote, uploadID string, part int, offset, size int64) error { func (c *Client) uploadPartWithRetry(ctx context.Context, file *os.File, remote, uploadID string, part int, offset, size int64) error {
var lastErr error var lastErr error
for attempt := 0; attempt < 3; attempt++ { for attempt := 0; attempt < maxUploadAttempts; attempt++ {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return err return err
} }
@@ -256,13 +257,18 @@ func (c *Client) uploadPartWithRetry(ctx context.Context, file *os.File, remote,
if lastErr == nil { if lastErr == nil {
return nil return nil
} }
if attempt < 2 { if attempt < maxUploadAttempts-1 {
if err := sleepContext(ctx, time.Duration(1<<attempt)*time.Second); err != nil { if err := sleepContext(ctx, c.uploadRetryDelay(attempt)); err != nil {
return err return err
} }
} }
} }
return lastErr return fmt.Errorf("upload failed after %d attempts: %w", maxUploadAttempts, lastErr)
}
func defaultUploadRetryDelay(attempt int) time.Duration {
delay := time.Second * time.Duration(1<<attempt)
return min(delay, 30*time.Second)
} }
func (c *Client) uploadPart(ctx context.Context, section *io.SectionReader, filename, remote, uploadID string, part int) error { func (c *Client) uploadPart(ctx context.Context, section *io.SectionReader, filename, remote, uploadID string, part int) error {