可靠性: 完善 R2 备份恢复与对象生命周期

This commit is contained in:
tamakyi
2026-11-05 08:41:55 +08:00
parent 3e7719f200
commit 937e688cfa
32 changed files with 4151 additions and 231 deletions
+128
View File
@@ -0,0 +1,128 @@
package storage
import (
"fmt"
"net/url"
"strings"
"unicode"
)
// ObjectKeyFromCDNURL 将属于 cdnBase 的 HTTPS URL 转成原始 S3 对象 key。
// cdnBase 可省略 scheme,也可带 base path。外站或不属于 base path 的 URL
// 返回 owned=false 且不报错;格式非法或试图伪装路径的 URL 返回错误。
func ObjectKeyFromCDNURL(rawURL, cdnBase string) (key string, owned bool, err error) {
base, err := parseCDNBase(cdnBase)
if err != nil {
return "", false, err
}
parsed, err := parseCDNURL(rawURL, false)
if err != nil {
return "", false, err
}
if !strings.EqualFold(parsed.Host, base.Host) {
return "", false, nil
}
basePath := base.Path
if basePath != "" {
if parsed.Path == basePath {
return "", false, fmt.Errorf("CDN URL does not contain an object key")
}
prefix := basePath + "/"
if !strings.HasPrefix(parsed.Path, prefix) {
return "", false, nil
}
key = strings.TrimPrefix(parsed.Path, prefix)
} else {
key = strings.TrimPrefix(parsed.Path, "/")
}
if key == "" {
return "", false, fmt.Errorf("CDN URL does not contain an object key")
}
// 只去掉 cdnBase 的路径和前导分隔符;不添加或移除 bucket,也不 Clean,
// 因而返回的解码路径仍是实际 S3 key。
return key, true, nil
}
func parseCDNBase(cdnBase string) (*url.URL, error) {
if cdnBase == "" {
return nil, fmt.Errorf("CDN base URL is empty")
}
rawBase := cdnBase
if strings.HasPrefix(rawBase, "//") {
rawBase = "https:" + rawBase
} else if !strings.Contains(rawBase, "://") {
rawBase = "https://" + rawBase
}
return parseCDNURL(rawBase, true)
}
func parseCDNURL(rawURL string, isBase bool) (*url.URL, error) {
if rawURL == "" {
return nil, fmt.Errorf("URL is empty")
}
if strings.Contains(rawURL, `\`) || containsControl(rawURL) {
return nil, fmt.Errorf("URL contains a backslash or control character")
}
parsed, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("parse URL: %w", err)
}
if !parsed.IsAbs() || !strings.EqualFold(parsed.Scheme, "https") || parsed.Host == "" ||
parsed.Hostname() == "" || parsed.Opaque != "" {
return nil, fmt.Errorf("URL must be an absolute HTTPS URL")
}
if parsed.User != nil {
return nil, fmt.Errorf("URL must not contain userinfo")
}
if parsed.RawQuery != "" || parsed.ForceQuery {
return nil, fmt.Errorf("URL must not contain a query")
}
if parsed.Fragment != "" || strings.Contains(rawURL, "#") {
return nil, fmt.Errorf("URL must not contain a fragment")
}
// 必须在 url.Parse 的 Path 解码之外检查 EscapedPath:编码斜杠在 Path 中
// 与普通分隔符无法区分,若放行会导致 CDN 与 S3 对 key 的解释不一致。
escapedPath := strings.ToLower(parsed.EscapedPath())
if strings.Contains(escapedPath, "%2f") || strings.Contains(escapedPath, "%5c") {
return nil, fmt.Errorf("URL path contains an encoded slash or backslash")
}
if strings.Contains(parsed.Path, `\`) || containsControl(parsed.Path) {
return nil, fmt.Errorf("URL path contains a backslash or control character")
}
if isBase && strings.HasSuffix(parsed.Path, "/") {
// 配置中的 CDN base 允许一个末尾斜杠;其余空路径段仍会被拒绝。
parsed.Path = strings.TrimSuffix(parsed.Path, "/")
parsed.RawPath = ""
}
if parsed.Path == "" {
if isBase {
return parsed, nil
}
return nil, fmt.Errorf("URL path is empty")
}
if !strings.HasPrefix(parsed.Path, "/") {
return nil, fmt.Errorf("URL path must be absolute")
}
for _, segment := range strings.Split(strings.TrimPrefix(parsed.Path, "/"), "/") {
if segment == "" {
return nil, fmt.Errorf("URL path contains an empty segment")
}
if segment == "." || segment == ".." {
return nil, fmt.Errorf("URL path contains a dot segment")
}
}
return parsed, nil
}
func containsControl(value string) bool {
for _, r := range value {
if unicode.IsControl(r) {
return true
}
}
return false
}
+112
View File
@@ -0,0 +1,112 @@
package storage
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestObjectKeyFromCDNURL(t *testing.T) {
tests := []struct {
name string
rawURL string
cdnBase string
wantKey string
owned bool
wantErr bool
}{
{
name: "scheme less base", rawURL: "https://cdn.example.com/2026/11/pic.webp",
cdnBase: "cdn.example.com", wantKey: "2026/11/pic.webp", owned: true,
},
{
name: "base path", rawURL: "https://cdn.example.com/assets/2026/pic.webp",
cdnBase: "https://cdn.example.com/assets", wantKey: "2026/pic.webp", owned: true,
},
{
name: "base trailing slash and escaped space", rawURL: "https://cdn.example.com/tamabox/a%20b.webp",
cdnBase: "cdn.example.com/tamabox/", wantKey: "a b.webp", owned: true,
},
{
name: "host case insensitive", rawURL: "https://CDN.EXAMPLE.COM/a.webp",
cdnBase: "cdn.example.com", wantKey: "a.webp", owned: true,
},
{
name: "preserve bucket prefix", rawURL: "https://cdn.example.com/tamabox/2026/a.webp",
cdnBase: "cdn.example.com", wantKey: "tamabox/2026/a.webp", owned: true,
},
{
name: "external host not owned", rawURL: "https://outside.example.com/a.webp",
cdnBase: "cdn.example.com", owned: false,
},
{
name: "host suffix attack not owned", rawURL: "https://cdn.example.com.evil.test/a.webp",
cdnBase: "cdn.example.com", owned: false,
},
{
name: "base path complete segment", rawURL: "https://cdn.example.com/assets2/a.webp",
cdnBase: "cdn.example.com/assets", owned: false,
},
{
name: "base path case sensitive", rawURL: "https://cdn.example.com/Assets/a.webp",
cdnBase: "cdn.example.com/assets", owned: false,
},
{name: "HTTP rejected", rawURL: "http://cdn.example.com/a", cdnBase: "cdn.example.com", wantErr: true},
{name: "relative rejected", rawURL: "/a", cdnBase: "cdn.example.com", wantErr: true},
{name: "scheme relative rejected", rawURL: "//cdn.example.com/a", cdnBase: "cdn.example.com", wantErr: true},
{name: "userinfo rejected", rawURL: "https://[email protected]/a", cdnBase: "cdn.example.com", wantErr: true},
{name: "query rejected", rawURL: "https://cdn.example.com/a?x=1", cdnBase: "cdn.example.com", wantErr: true},
{name: "empty query rejected", rawURL: "https://cdn.example.com/a?", cdnBase: "cdn.example.com", wantErr: true},
{name: "fragment rejected", rawURL: "https://cdn.example.com/a#fragment", cdnBase: "cdn.example.com", wantErr: true},
{name: "empty fragment rejected", rawURL: "https://cdn.example.com/a#", cdnBase: "cdn.example.com", wantErr: true},
{name: "backslash rejected", rawURL: `https://cdn.example.com/a\b`, cdnBase: "cdn.example.com", wantErr: true},
{name: "encoded backslash rejected", rawURL: "https://cdn.example.com/a%5Cb", cdnBase: "cdn.example.com", wantErr: true},
{name: "encoded slash rejected", rawURL: "https://cdn.example.com/a%2Fb", cdnBase: "cdn.example.com", wantErr: true},
{name: "control rejected", rawURL: "https://cdn.example.com/a\x7fb", cdnBase: "cdn.example.com", wantErr: true},
{name: "encoded control rejected", rawURL: "https://cdn.example.com/a%0Ab", cdnBase: "cdn.example.com", wantErr: true},
{name: "dot rejected", rawURL: "https://cdn.example.com/a/./b", cdnBase: "cdn.example.com", wantErr: true},
{name: "dot dot rejected", rawURL: "https://cdn.example.com/a/../b", cdnBase: "cdn.example.com", wantErr: true},
{name: "encoded dot dot rejected", rawURL: "https://cdn.example.com/a/%2E%2E/b", cdnBase: "cdn.example.com", wantErr: true},
{name: "empty path rejected", rawURL: "https://cdn.example.com", cdnBase: "cdn.example.com", wantErr: true},
{name: "root path rejected", rawURL: "https://cdn.example.com/", cdnBase: "cdn.example.com", wantErr: true},
{name: "empty middle segment rejected", rawURL: "https://cdn.example.com/a//b", cdnBase: "cdn.example.com", wantErr: true},
{name: "empty last segment rejected", rawURL: "https://cdn.example.com/a/", cdnBase: "cdn.example.com", wantErr: true},
{name: "missing key below base rejected", rawURL: "https://cdn.example.com/assets", cdnBase: "cdn.example.com/assets", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key, owned, err := ObjectKeyFromCDNURL(tt.rawURL, tt.cdnBase)
if tt.wantErr {
require.Error(t, err)
assert.False(t, owned)
assert.Empty(t, key)
return
}
require.NoError(t, err)
assert.Equal(t, tt.owned, owned)
assert.Equal(t, tt.wantKey, key)
})
}
}
func TestObjectKeyFromCDNURLRejectsInvalidBase(t *testing.T) {
for _, base := range []string{
"",
"http://cdn.example.com",
"cdn.example.com?x=1",
"cdn.example.com#fragment",
"cdn.example.com/assets/../other",
"cdn.example.com/assets//other",
"cdn.example.com/assets%2Fother",
"[email protected]",
} {
t.Run(base, func(t *testing.T) {
key, owned, err := ObjectKeyFromCDNURL("https://cdn.example.com/assets/a", base)
require.Error(t, err)
assert.False(t, owned)
assert.Empty(t, key)
})
}
}
+6 -25
View File
@@ -5,9 +5,6 @@ import (
"sync"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/pkg/errors"
@@ -31,29 +28,13 @@ func GetS3Client() (*s3.Client, error) {
s3ClientErr = errors.New("S3 endpoint not configured")
return
}
r2Resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
return aws.Endpoint{
URL: conf.Upload.ImageEndpoint,
HostnameImmutable: true,
Source: aws.EndpointSourceCustom,
}, nil
})
cfg, err := config.LoadDefaultConfig(context.Background(),
config.WithEndpointResolverWithOptions(r2Resolver),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
conf.Upload.ImageAccessID,
conf.Upload.ImageAccessSecret,
"",
)),
config.WithRegion("auto"),
s3Client, s3ClientErr = NewS3Client(
context.Background(),
conf.Upload.ImageEndpoint,
conf.Upload.ImageBucket,
conf.Upload.ImageAccessID,
conf.Upload.ImageAccessSecret,
)
if err != nil {
s3ClientErr = errors.Wrap(err, "load S3 config")
return
}
s3Client = s3.NewFromConfig(cfg)
})
return s3Client, s3ClientErr
}
+11 -14
View File
@@ -23,7 +23,6 @@ import (
"github.com/disintegration/imaging"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/wuhan005/gadget"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/imagecheck"
@@ -189,19 +188,17 @@ func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, as
return "", errors.Wrap(err, "get S3 client")
}
// 7. 上传到 R2
if err := gadget.Retry(5, func() error {
_, err := client.PutObject(context.Background(), &s3.PutObjectInput{
Bucket: aws.String(conf.Upload.ImageBucket),
Key: aws.String(key),
Body: bytes.NewReader(uploadData),
ContentLength: aws.Int64(int64(len(uploadData))),
ContentType: aws.String(contentType),
// key 含纳秒时间戳且内容不可变 → 可安全启用 immutable 强缓存,
// 避免每次页面加载都回源校验(曾导致头像/二维码 logo 每次重新下载)。
CacheControl: aws.String("public, max-age=31536000, immutable"),
})
return err
// 7. 上传到 R2。现有上传 API 不接收调用方 context,故显式标记为 TODO;
// PutObject 内部会为每次尝试设置独立超时,并对可取消 context 做退避。
if err := PutObject(context.TODO(), client, &s3.PutObjectInput{
Bucket: aws.String(conf.Upload.ImageBucket),
Key: aws.String(key),
Body: bytes.NewReader(uploadData),
ContentLength: aws.Int64(int64(len(uploadData))),
ContentType: aws.String(contentType),
// key 含纳秒时间戳且内容不可变 → 可安全启用 immutable 强缓存,
// 避免每次页面加载都回源校验(曾导致头像/二维码 logo 每次重新下载)。
CacheControl: aws.String("public, max-age=31536000, immutable"),
}); err != nil {
return "", errors.Wrap(err, "put object after retry")
}
+112
View File
@@ -0,0 +1,112 @@
package storage
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
const (
s3DialTimeout = 10 * time.Second
s3TLSHandshakeTimeout = 10 * time.Second
s3ResponseHeaderTimeout = 30 * time.Second
)
// NewS3Client 构造用于 Cloudflare R2 的 S3 客户端。
// endpoint 可以不带路径,也可以带且仅带 /<bucket>;后者会被剥离,
// 再由 path-style 请求恰好添加一次 bucket,避免出现 /bucket/bucket/key。
func NewS3Client(ctx context.Context, endpoint, bucket, accessID, accessSecret string) (*s3.Client, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
normalizedEndpoint, err := normalizeS3Endpoint(endpoint, bucket)
if err != nil {
return nil, err
}
// 显式构造配置,不加载本机 AWS 配置或默认凭据链,避免离线环境意外访问
// 元数据服务,也保证这里只使用调用方提供的静态凭据。
cfg := aws.Config{
Region: "auto",
BaseEndpoint: aws.String(normalizedEndpoint),
Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(
accessID,
accessSecret,
"",
)),
HTTPClient: newS3HTTPClient(),
Retryer: func() aws.Retryer {
return aws.NopRetryer{}
},
}
return s3.NewFromConfig(cfg, func(options *s3.Options) {
options.UsePathStyle = true
}), nil
}
func normalizeS3Endpoint(endpoint, bucket string) (string, error) {
if bucket == "" || strings.ContainsAny(bucket, `/\`) {
return "", fmt.Errorf("invalid S3 bucket %q", bucket)
}
parsed, err := url.Parse(endpoint)
if err != nil {
return "", fmt.Errorf("parse S3 endpoint: %w", err)
}
if !parsed.IsAbs() || parsed.Host == "" || parsed.Hostname() == "" || parsed.Opaque != "" ||
(!strings.EqualFold(parsed.Scheme, "https") && !strings.EqualFold(parsed.Scheme, "http")) {
return "", fmt.Errorf("S3 endpoint must be an absolute HTTP(S) URL: %q", endpoint)
}
if parsed.User != nil {
return "", fmt.Errorf("S3 endpoint must not contain userinfo")
}
if parsed.RawQuery != "" || parsed.ForceQuery {
return "", fmt.Errorf("S3 endpoint must not contain a query")
}
if parsed.Fragment != "" || strings.Contains(endpoint, "#") {
return "", fmt.Errorf("S3 endpoint must not contain a fragment")
}
// EscapedPath 用于精确比较,拒绝用 %62ucket 等编码形式绕过路径约束。
// 空路径保持原样;唯一允许的非空路径是 /<bucket>,包括 "/" 在内的
// 其他路径均拒绝,避免静默改变 endpoint 的语义。
switch parsed.EscapedPath() {
case "":
return parsed.String(), nil
case "/" + bucket:
normalized := *parsed
normalized.Path = ""
normalized.RawPath = ""
return normalized.String(), nil
default:
return "", fmt.Errorf("S3 endpoint path must be empty or exactly /%s", bucket)
}
}
func newS3HTTPClient() *http.Client {
dialer := &net.Dialer{
Timeout: s3DialTimeout,
KeepAlive: 30 * time.Second,
}
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: dialer.DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: s3TLSHandshakeTimeout,
ResponseHeaderTimeout: s3ResponseHeaderTimeout,
ExpectContinueTimeout: 1 * time.Second,
}
// 不设置 http.Client.Timeout:该超时覆盖读取/写入响应的整个生命周期,
// 会截断大对象传输。每次 PutObject/DeleteObject 尝试另有独立 60 秒上限。
return &http.Client{Transport: transport}
}
+126
View File
@@ -0,0 +1,126 @@
package storage
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNormalizeS3Endpoint(t *testing.T) {
tests := []struct {
name string
endpoint string
bucket string
want string
wantErr bool
}{
{name: "empty path", endpoint: "https://account.example.com", bucket: "images", want: "https://account.example.com"},
{name: "strip exact bucket path", endpoint: "https://account.example.com/images", bucket: "images", want: "https://account.example.com"},
{name: "httptest HTTP supported", endpoint: "http://127.0.0.1:12345/images", bucket: "images", want: "http://127.0.0.1:12345"},
{name: "root path rejected", endpoint: "https://account.example.com/", bucket: "images", wantErr: true},
{name: "different path rejected", endpoint: "https://account.example.com/other", bucket: "images", wantErr: true},
{name: "bucket trailing slash rejected", endpoint: "https://account.example.com/images/", bucket: "images", wantErr: true},
{name: "encoded bucket rejected", endpoint: "https://account.example.com/%69mages", bucket: "images", wantErr: true},
{name: "relative rejected", endpoint: "account.example.com", bucket: "images", wantErr: true},
{name: "empty hostname rejected", endpoint: "https://:443", bucket: "images", wantErr: true},
{name: "unsupported scheme rejected", endpoint: "ftp://account.example.com", bucket: "images", wantErr: true},
{name: "query rejected", endpoint: "https://account.example.com?x=1", bucket: "images", wantErr: true},
{name: "empty query rejected", endpoint: "https://account.example.com?", bucket: "images", wantErr: true},
{name: "fragment rejected", endpoint: "https://account.example.com#fragment", bucket: "images", wantErr: true},
{name: "userinfo rejected", endpoint: "https://[email protected]", bucket: "images", wantErr: true},
{name: "empty bucket rejected", endpoint: "https://account.example.com", bucket: "", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := normalizeS3Endpoint(tt.endpoint, tt.bucket)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestNewS3ClientConfiguration(t *testing.T) {
client, err := NewS3Client(context.Background(), "https://account.example.com/images", "images", "test-id", "test-secret")
require.NoError(t, err)
options := client.Options()
assert.Equal(t, "auto", options.Region)
assert.True(t, options.UsePathStyle)
credentials, err := options.Credentials.Retrieve(context.Background())
require.NoError(t, err)
assert.Equal(t, "test-id", credentials.AccessKeyID)
assert.Equal(t, "test-secret", credentials.SecretAccessKey)
httpClient, ok := options.HTTPClient.(*http.Client)
require.True(t, ok)
assert.Zero(t, httpClient.Timeout, "全局 Client.Timeout 会截断大对象传输")
transport, ok := httpClient.Transport.(*http.Transport)
require.True(t, ok)
assert.NotNil(t, transport.DialContext)
assert.Positive(t, transport.TLSHandshakeTimeout)
assert.Positive(t, transport.ResponseHeaderTimeout)
assert.Positive(t, transport.ExpectContinueTimeout)
}
func TestS3PutObjectRequestShape(t *testing.T) {
type capturedRequest struct {
method string
path string
body string
contentType string
cacheControl string
authorization string
}
captured := make(chan capturedRequest, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
captured <- capturedRequest{
method: r.Method,
path: r.URL.Path,
body: string(body),
contentType: r.Header.Get("Content-Type"),
cacheControl: r.Header.Get("Cache-Control"),
authorization: r.Header.Get("Authorization"),
}
w.Header().Set("ETag", `"test-etag"`)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client, err := NewS3Client(context.Background(), server.URL+"/images", "images", "test-id", "test-secret")
require.NoError(t, err)
err = PutObject(context.Background(), client, &s3.PutObjectInput{
Bucket: aws.String("images"),
Key: aws.String("2026/11/picture.webp"),
Body: bytes.NewReader([]byte("fake-image")),
ContentLength: aws.Int64(10),
ContentType: aws.String("image/webp"),
CacheControl: aws.String("public, max-age=31536000, immutable"),
})
require.NoError(t, err)
request := <-captured
assert.Equal(t, http.MethodPut, request.method)
assert.Equal(t, "/images/2026/11/picture.webp", request.path, "bucket 必须且只能出现一次")
assert.Equal(t, "fake-image", request.body)
assert.Equal(t, "image/webp", request.contentType)
assert.Equal(t, "public, max-age=31536000, immutable", request.cacheControl)
assert.Contains(t, request.authorization, "Credential=test-id/")
assert.Contains(t, request.authorization, "/auto/s3/aws4_request")
}
+167
View File
@@ -0,0 +1,167 @@
package storage
import (
"context"
"errors"
"fmt"
"io"
"net"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/smithy-go"
)
const (
s3AttemptTimeout = 60 * time.Second
s3MaxAttempts = 3
s3InitialRetryBackoff = 100 * time.Millisecond
)
// PutObject 上传对象。每次尝试独立限时 60 秒,最多执行 3 次;
// SDK 内部重试被关闭,确保重试次数、Body 回绕和错误分类只由本层控制。
func PutObject(ctx context.Context, client *s3.Client, input *s3.PutObjectInput) error {
if client == nil {
return errors.New("nil S3 client")
}
if input == nil {
return errors.New("nil S3 PutObject input")
}
return doS3RequestWithRetry(ctx, input.Body, func(attemptCtx context.Context) error {
_, err := client.PutObject(attemptCtx, input, func(options *s3.Options) {
options.Retryer = aws.NopRetryer{}
})
return err
})
}
// DeleteObject 删除对象。S3 Delete 本身幂等;404 也表示目标已经不存在,
// 因而不重试并返回成功。其他错误采用与 PutObject 相同的超时和重试规则。
func DeleteObject(ctx context.Context, client *s3.Client, bucket, key string) error {
if client == nil {
return errors.New("nil S3 client")
}
err := doS3RequestWithRetry(ctx, nil, func(attemptCtx context.Context) error {
_, err := client.DeleteObject(attemptCtx, &s3.DeleteObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
}, func(options *s3.Options) {
options.Retryer = aws.NopRetryer{}
})
return err
})
if err != nil {
if statusCode, ok := s3ErrorHTTPStatus(err); ok && statusCode == 404 {
return nil
}
}
return err
}
func doS3RequestWithRetry(ctx context.Context, body io.Reader, operation func(context.Context) error) error {
var (
seeker io.Seeker
bodyStart int64
bodyStartErr error
)
if body != nil {
if candidate, ok := body.(io.Seeker); ok {
seeker = candidate
// 记录调用时的位置,而不是武断跳到 0,兼容从切片区间上传的 Reader。
bodyStart, bodyStartErr = seeker.Seek(0, io.SeekCurrent)
}
}
for attempt := 0; attempt < s3MaxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return err
}
if attempt > 0 && seeker != nil {
if bodyStartErr != nil {
return fmt.Errorf("determine S3 request body start: %w", bodyStartErr)
}
if _, err := seeker.Seek(bodyStart, io.SeekStart); err != nil {
return fmt.Errorf("rewind S3 request body: %w", err)
}
}
attemptCtx, cancel := context.WithTimeout(ctx, s3AttemptTimeout)
err := operation(attemptCtx)
cancel()
if err == nil {
return nil
}
if ctx.Err() != nil {
return ctx.Err()
}
if attempt == s3MaxAttempts-1 || !retryableS3Error(err) {
return err
}
backoff := s3InitialRetryBackoff << attempt
if err := waitForS3Retry(ctx, backoff); err != nil {
return err
}
}
return nil
}
func waitForS3Retry(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
type httpStatusCoder interface {
HTTPStatusCode() int
}
func s3ErrorHTTPStatus(err error) (int, bool) {
var statusErr httpStatusCoder
if errors.As(err, &statusErr) {
return statusErr.HTTPStatusCode(), true
}
return 0, false
}
func retryableS3Error(err error) bool {
if err == nil {
return false
}
if statusCode, ok := s3ErrorHTTPStatus(err); ok {
return statusCode == 408 || statusCode == 429 || statusCode >= 500 && statusCode <= 599
}
if errors.Is(err, context.Canceled) {
return false
}
// 父 context 已结束时会在调用方提前返回;这里的 DeadlineExceeded 是
// 单次尝试或传输层超时,可以在父 context 仍有效时重试。
if errors.Is(err, context.DeadlineExceeded) {
return true
}
var networkErr net.Error
if errors.As(err, &networkErr) {
return true
}
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
// 某些兼容 S3 的实现可能只返回错误码而没有可提取的 HTTP 状态。
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
switch apiErr.ErrorCode() {
case "RequestTimeout", "RequestTimeoutException",
"Throttling", "ThrottlingException", "TooManyRequestsException", "SlowDown",
"InternalError", "InternalServerError", "ServiceUnavailable", "ServiceUnavailableException":
return true
}
}
return false
}
+171
View File
@@ -0,0 +1,171 @@
package storage
import (
"bytes"
"context"
"io"
"net"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPutObjectRetriesAndRewindsBody(t *testing.T) {
var attempts atomic.Int32
bodies := make(chan string, 3)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
bodies <- string(body)
if attempts.Add(1) < 3 {
http.Error(w, "temporary", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client, err := NewS3Client(context.Background(), server.URL, "images", "id", "secret")
require.NoError(t, err)
err = PutObject(context.Background(), client, &s3.PutObjectInput{
Bucket: aws.String("images"),
Key: aws.String("retry.txt"),
Body: bytes.NewReader([]byte("complete-body")),
})
require.NoError(t, err)
assert.Equal(t, int32(3), attempts.Load())
close(bodies)
for body := range bodies {
assert.Equal(t, "complete-body", body)
}
}
func TestPutObjectDoesNotRetryPermanentErrors(t *testing.T) {
for _, statusCode := range []int{http.StatusForbidden, http.StatusNotFound} {
t.Run(http.StatusText(statusCode), func(t *testing.T) {
var attempts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
http.Error(w, "permanent", statusCode)
}))
defer server.Close()
client, err := NewS3Client(context.Background(), server.URL, "images", "id", "secret")
require.NoError(t, err)
err = PutObject(context.Background(), client, &s3.PutObjectInput{
Bucket: aws.String("images"),
Key: aws.String("permanent.txt"),
Body: bytes.NewReader([]byte("body")),
})
require.Error(t, err)
assert.Equal(t, int32(1), attempts.Load())
})
}
}
func TestPutObjectRetriesTemporaryStatusCodes(t *testing.T) {
for _, statusCode := range []int{http.StatusRequestTimeout, http.StatusTooManyRequests, http.StatusBadGateway} {
t.Run(http.StatusText(statusCode), func(t *testing.T) {
var attempts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if attempts.Add(1) == 1 {
http.Error(w, "temporary", statusCode)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client, err := NewS3Client(context.Background(), server.URL, "images", "id", "secret")
require.NoError(t, err)
err = PutObject(context.Background(), client, &s3.PutObjectInput{
Bucket: aws.String("images"),
Key: aws.String("temporary.txt"),
Body: bytes.NewReader([]byte("body")),
})
require.NoError(t, err)
assert.Equal(t, int32(2), attempts.Load())
})
}
}
func TestPutObjectStopsAfterThreeAttempts(t *testing.T) {
var attempts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
http.Error(w, "still unavailable", http.StatusServiceUnavailable)
}))
defer server.Close()
client, err := NewS3Client(context.Background(), server.URL, "images", "id", "secret")
require.NoError(t, err)
err = PutObject(context.Background(), client, &s3.PutObjectInput{
Bucket: aws.String("images"),
Key: aws.String("unavailable.txt"),
Body: bytes.NewReader([]byte("body")),
})
require.Error(t, err)
assert.Equal(t, int32(3), attempts.Load())
}
func TestPutObjectHonorsCanceledContext(t *testing.T) {
var attempts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client, err := NewS3Client(context.Background(), server.URL, "images", "id", "secret")
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
cancel()
err = PutObject(ctx, client, &s3.PutObjectInput{
Bucket: aws.String("images"),
Key: aws.String("canceled.txt"),
Body: bytes.NewReader([]byte("body")),
})
require.ErrorIs(t, err, context.Canceled)
assert.Zero(t, attempts.Load())
}
func TestRetryBackoffCanBeCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- waitForS3Retry(ctx, time.Hour)
}()
cancel()
select {
case err := <-done:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(time.Second):
t.Fatal("退避未响应 context 取消")
}
}
func TestNetworkErrorsAreRetryable(t *testing.T) {
assert.True(t, retryableS3Error(&net.DNSError{Err: "temporary resolver failure", IsTimeout: true}))
}
func TestDeleteObjectNotFoundIsIdempotent(t *testing.T) {
var attempts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
assert.Equal(t, http.MethodDelete, r.Method)
http.Error(w, "not found", http.StatusNotFound)
}))
defer server.Close()
client, err := NewS3Client(context.Background(), server.URL, "images", "id", "secret")
require.NoError(t, err)
require.NoError(t, DeleteObject(context.Background(), client, "images", "already-gone.txt"))
assert.Equal(t, int32(1), attempts.Load())
}