168 lines
4.4 KiB
Go
168 lines
4.4 KiB
Go
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
|
|
}
|