可靠性: 完善 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
+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())
}