diff --git a/drivers/baidu_netdisk/driver.go b/drivers/baidu_netdisk/driver.go
index fe77aca38b..cda878605b 100644
--- a/drivers/baidu_netdisk/driver.go
+++ b/drivers/baidu_netdisk/driver.go
@@ -33,7 +33,8 @@ type BaiduNetdisk struct {
Addition
uploadThread int
- vipType int // 会员类型,0普通用户(4G/4M)、1普通会员(10G/16M)、2超级会员(20G/32M)
+ vipType int // 会员类型,0普通用户(4G/4M)、1普通会员(10G/16M)、2超级会员(20G/32M)
+ cooldown *cooldown // shared pause after Baidu rate limiting (31034)
}
var ErrUploadIDExpired = errors.New("uploadid expired")
@@ -47,6 +48,9 @@ func (d *BaiduNetdisk) GetAddition() driver.Additional {
}
func (d *BaiduNetdisk) Init(ctx context.Context) error {
+ if d.cooldown == nil {
+ d.cooldown = newCooldown()
+ }
d.uploadThread, _ = strconv.Atoi(d.UploadThread)
if d.uploadThread < 1 {
d.uploadThread, d.UploadThread = 1, "1"
@@ -110,7 +114,7 @@ func (d *BaiduNetdisk) Move(ctx context.Context, srcObj, dstDir model.Obj) (mode
"newname": srcObj.GetName(),
},
}
- _, err := d.manage("move", data)
+ _, err := d.manage(ctx, "move", data)
if err != nil {
return nil, err
}
@@ -129,7 +133,7 @@ func (d *BaiduNetdisk) Rename(ctx context.Context, srcObj model.Obj, newName str
"newname": newName,
},
}
- _, err := d.manage("rename", data)
+ _, err := d.manage(ctx, "rename", data)
if err != nil {
return nil, err
}
@@ -151,13 +155,18 @@ func (d *BaiduNetdisk) Copy(ctx context.Context, srcObj, dstDir model.Obj) error
"newname": srcObj.GetName(),
},
}
- _, err := d.manage("copy", data)
+ _, err := d.manage(ctx, "copy", data)
return err
}
func (d *BaiduNetdisk) Remove(ctx context.Context, obj model.Obj) error {
data := []string{obj.GetPath()}
- _, err := d.manage("delete", data)
+ _, err := d.manage(ctx, "delete", data)
+ if e, ok := asAPIError(err); ok && e.NotExist() {
+ // already gone: deleting is idempotent
+ log.Debugf("[baidu_netdisk] %s already removed (errno %d)", obj.GetPath(), e.Errno)
+ return nil
+ }
return err
}
diff --git a/drivers/baidu_netdisk/ratelimit.go b/drivers/baidu_netdisk/ratelimit.go
new file mode 100644
index 0000000000..98335a3d3b
--- /dev/null
+++ b/drivers/baidu_netdisk/ratelimit.go
@@ -0,0 +1,287 @@
+package baidu_netdisk
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math/rand"
+ "sync"
+ "time"
+
+ "github.com/OpenListTeam/OpenList/v4/internal/errs"
+ "github.com/OpenListTeam/OpenList/v4/pkg/utils"
+ log "github.com/sirupsen/logrus"
+)
+
+// Baidu error codes handled specially.
+const (
+ errnoBatchFailed = 12 // filemanager: some items of the batch failed, see info[].errno
+ errnoNotExist = -9 // file or directory does not exist
+ errnoPCSNotExist = 31066 // PCS: file does not exist
+ errnoRateLimited = 31034 // hit API frequency control
+ cooldownBase = 8 * time.Second
+ cooldownMax = 30 * time.Second
+ // maxCooldownWaitPerCall bounds how long a single API call may wait for
+ // the breaker. Past that the call fails with a rate-limit error so callers
+ // (e.g. S3 clients receiving SlowDown) can back off on their own.
+ maxCooldownWaitPerCall = 35 * time.Second
+)
+
+// apiItem is one entry of a filemanager response's info list.
+type apiItem struct {
+ Errno int `json:"errno"`
+ Path string `json:"path"`
+}
+
+// apiError is a non-zero errno returned by the Baidu API.
+type apiError struct {
+ URL string
+ Errno int
+ Items []apiItem // failed items of a filemanager batch, if any
+}
+
+func (e *apiError) Error() string {
+ if len(e.Items) > 0 {
+ return fmt.Sprintf("req: [%s] ,errno: %d, item errno: %d (path %s), refer to https://pan.baidu.com/union/doc/",
+ e.URL, e.Errno, e.Items[0].Errno, e.Items[0].Path)
+ }
+ return fmt.Sprintf("req: [%s] ,errno: %d, refer to https://pan.baidu.com/union/doc/", e.URL, e.Errno)
+}
+
+// RateLimited reports whether Baidu rejected the request with frequency control,
+// either directly or for any item of a batch.
+func (e *apiError) RateLimited() bool {
+ if e.Errno == errnoRateLimited {
+ return true
+ }
+ for _, it := range e.Items {
+ if it.Errno == errnoRateLimited {
+ return true
+ }
+ }
+ return false
+}
+
+// NotExist reports whether the request failed only because the target(s) do not exist.
+func (e *apiError) NotExist() bool {
+ if isNotExistErrno(e.Errno) {
+ return true
+ }
+ if e.Errno != errnoBatchFailed || len(e.Items) == 0 {
+ return false
+ }
+ for _, it := range e.Items {
+ if !isNotExistErrno(it.Errno) {
+ return false
+ }
+ }
+ return true
+}
+
+// Unwrap lets errors.Is(err, errs.TemporaryCapacity) match rate-limit errors,
+// which the S3 server maps to 503 SlowDown.
+func (e *apiError) Unwrap() error {
+ if e.RateLimited() {
+ return errs.TemporaryCapacity
+ }
+ return nil
+}
+
+func isNotExistErrno(n int) bool { return n == errnoNotExist || n == errnoPCSNotExist }
+
+// parseAPIError returns nil when body has errno 0. For a failed filemanager
+// batch it keeps the failed info[] items, which carry the real cause.
+func parseAPIError(furl string, body []byte) *apiError {
+ var r struct {
+ Errno int `json:"errno"`
+ Info []apiItem `json:"info"`
+ }
+ if err := utils.Json.Unmarshal(body, &r); err != nil {
+ // keep the previous behaviour for odd bodies: only look at errno
+ r.Errno = utils.Json.Get(body, "errno").ToInt()
+ r.Info = nil
+ }
+ if r.Errno == 0 {
+ return nil
+ }
+ e := &apiError{URL: furl, Errno: r.Errno}
+ for _, it := range r.Info {
+ if it.Errno != 0 {
+ e.Items = append(e.Items, it)
+ }
+ }
+ return e
+}
+
+func asAPIError(err error) (*apiError, bool) {
+ var e *apiError
+ ok := errors.As(err, &e)
+ return e, ok
+}
+
+// outcome of a request made under a permit.
+type outcome int
+
+const (
+ outcomeSuccess outcome = iota
+ outcomeRateLimited
+ outcomeOther // any other error, including ctx cancellation
+)
+
+// cooldown is a small circuit breaker shared by all requests of one driver
+// instance. When Baidu answers 31034 the breaker opens and every request
+// pauses. When the pause ends exactly one request is let through as a probe
+// while the rest keep waiting:
+// - probe succeeds: the breaker closes and all waiters proceed;
+// - probe gets 31034: the pause is extended (doubling, capped) and the next
+// expiry probes again;
+// - probe fails otherwise or is cancelled: the probe slot passes to the next
+// waiter.
+//
+// There is no limit while the breaker is closed.
+type cooldown struct {
+ mu sync.Mutex
+ tripped bool // breaker open
+ until time.Time // end of the current pause
+ penalty time.Duration // length of the next pause
+ probing bool // a probe is in flight
+ changed chan struct{} // closed and replaced on every state change
+ probes int // probes granted (for tests and logs)
+
+ base, max time.Duration // pause bounds
+ maxWait time.Duration // per-call wait budget
+
+ now func() time.Time
+ jitter func() time.Duration
+}
+
+func newCooldown() *cooldown {
+ c := &cooldown{
+ penalty: cooldownBase,
+ base: cooldownBase,
+ max: cooldownMax,
+ maxWait: maxCooldownWaitPerCall,
+ changed: make(chan struct{}),
+ now: time.Now,
+ }
+ // up to 1/8 of the base pause (1s by default), so instances do not resume in lockstep
+ c.jitter = func() time.Duration { return time.Duration(rand.Int63n(int64(c.base/8) + 1)) }
+ return c
+}
+
+// budget is the per-call wait limit.
+func (c *cooldown) budget() time.Duration {
+ if c == nil {
+ return 0
+ }
+ return c.maxWait
+}
+
+// permit is held by one request between acquire and done.
+type permit struct {
+ c *cooldown
+ probe bool
+ once sync.Once
+}
+
+// done reports the request's outcome. It must be called exactly once per
+// permit; extra calls are ignored. A nil permit is a no-op.
+func (p *permit) done(o outcome) {
+ if p == nil || p.c == nil {
+ return
+ }
+ p.once.Do(func() { p.c.report(p.probe, o) })
+}
+
+// acquire waits until the request may be sent. It returns ok=false without
+// sending once waiting would exceed budget, and ctx's error if ctx ends first.
+// waited is the time spent waiting either way.
+func (c *cooldown) acquire(ctx context.Context, budget time.Duration) (p *permit, waited time.Duration, ok bool, err error) {
+ if c == nil {
+ return nil, 0, true, nil
+ }
+ start := c.now()
+ for {
+ c.mu.Lock()
+ now := c.now()
+ waited = now.Sub(start)
+ if !c.tripped {
+ c.mu.Unlock()
+ return &permit{c: c}, waited, true, nil
+ }
+ wait := c.until.Sub(now) // > 0: pause still running
+ if wait <= 0 && !c.probing {
+ c.probing = true
+ c.probes++
+ c.mu.Unlock()
+ return &permit{c: c, probe: true}, waited, true, nil
+ }
+ ch := c.changed
+ c.mu.Unlock()
+
+ left := budget - waited
+ if left <= 0 || wait > left {
+ return nil, waited, false, nil
+ }
+ if wait <= 0 { // a probe is in flight: wait for its report
+ wait = left
+ }
+ t := time.NewTimer(wait)
+ select {
+ case <-ch:
+ case <-t.C:
+ case <-ctx.Done():
+ t.Stop()
+ return nil, c.now().Sub(start), false, ctx.Err()
+ }
+ t.Stop()
+ }
+}
+
+func (c *cooldown) report(probe bool, o outcome) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ now := c.now()
+ switch {
+ case probe:
+ c.probing = false
+ switch o {
+ case outcomeSuccess:
+ c.tripped = false
+ c.penalty = c.base
+ log.Infof("[baidu_netdisk] rate limit probe succeeded, resuming requests")
+ case outcomeRateLimited:
+ d := c.pause(now)
+ log.Warnf("[baidu_netdisk] rate limit probe still throttled (31034), pausing requests for %s", d.Round(time.Millisecond))
+ default:
+ log.Debugf("[baidu_netdisk] rate limit probe failed without a verdict, handing over")
+ }
+ c.broadcast()
+ case o == outcomeRateLimited && !c.tripped:
+ c.tripped = true
+ d := c.pause(now)
+ log.Warnf("[baidu_netdisk] hit rate limit (31034), pausing requests for %s", d.Round(time.Millisecond))
+ c.broadcast()
+ }
+ // Other outcomes of non-probe requests, e.g. 31034 for requests sent
+ // before the breaker opened, change nothing.
+}
+
+// pause starts a new pause and escalates the next one. c.mu must be held.
+func (c *cooldown) pause(now time.Time) time.Duration {
+ if c.penalty <= 0 {
+ c.penalty = c.base
+ }
+ d := c.penalty + c.jitter()
+ c.until = now.Add(d)
+ c.penalty *= 2
+ if c.penalty > c.max {
+ c.penalty = c.max
+ }
+ return d
+}
+
+func (c *cooldown) broadcast() {
+ close(c.changed)
+ c.changed = make(chan struct{})
+}
diff --git a/drivers/baidu_netdisk/ratelimit_test.go b/drivers/baidu_netdisk/ratelimit_test.go
new file mode 100644
index 0000000000..13b3ea897c
--- /dev/null
+++ b/drivers/baidu_netdisk/ratelimit_test.go
@@ -0,0 +1,445 @@
+package baidu_netdisk
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "runtime"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/OpenListTeam/OpenList/v4/drivers/base"
+ "github.com/OpenListTeam/OpenList/v4/internal/errs"
+ "github.com/OpenListTeam/OpenList/v4/internal/model"
+ "github.com/go-resty/resty/v2"
+)
+
+// Real body captured from Baidu while deleting quickly (path shortened).
+const rateLimitedBatchBody = `{"errno":12,"info":[{"errno":31034,"path":"\/alist\/x\/q0120"}],"request_id":9111630116005502295}`
+
+func TestParseAPIError(t *testing.T) {
+ cases := []struct {
+ name string
+ body string
+ wantNil bool
+ wantRL, wantNotExt bool
+ }{
+ {name: "success", body: `{"errno":0,"info":[{"errno":0,"path":"/a"}]}`, wantNil: true},
+ {name: "no errno field", body: `{"list":[]}`, wantNil: true},
+ {name: "batch rate limited", body: rateLimitedBatchBody, wantRL: true},
+ {name: "outer rate limited", body: `{"errno":31034}`, wantRL: true},
+ {name: "batch not exist", body: `{"errno":12,"info":[{"errno":-9,"path":"/a"}]}`, wantNotExt: true},
+ {name: "batch pcs not exist", body: `{"errno":12,"info":[{"errno":31066,"path":"/a"}]}`, wantNotExt: true},
+ {name: "outer not exist", body: `{"errno":-9}`, wantNotExt: true},
+ {name: "batch mixed", body: `{"errno":12,"info":[{"errno":-9,"path":"/a"},{"errno":-7,"path":"/b"}]}`},
+ {name: "batch without info", body: `{"errno":12}`},
+ {name: "token", body: `{"errno":-6}`},
+ {name: "info not a list", body: `{"errno":2,"info":"x"}`},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ e := parseAPIError("u", []byte(c.body))
+ if c.wantNil {
+ if e != nil {
+ t.Fatalf("want nil, got %v", e)
+ }
+ return
+ }
+ if e == nil {
+ t.Fatal("want error, got nil")
+ }
+ if e.RateLimited() != c.wantRL {
+ t.Errorf("RateLimited = %v, want %v", e.RateLimited(), c.wantRL)
+ }
+ if e.NotExist() != c.wantNotExt {
+ t.Errorf("NotExist = %v, want %v", e.NotExist(), c.wantNotExt)
+ }
+ if got := errors.Is(e, errs.TemporaryCapacity); got != c.wantRL {
+ t.Errorf("errors.Is(TemporaryCapacity) = %v, want %v", got, c.wantRL)
+ }
+ })
+ }
+ e := parseAPIError("https://pan.baidu.com/rest/2.0/xpan/file", []byte(rateLimitedBatchBody))
+ if !strings.Contains(e.Error(), "31034") || !strings.Contains(e.Error(), "q0120") {
+ t.Errorf("message should carry item errno and path: %s", e.Error())
+ }
+}
+
+func newTestBreaker(base, max, maxWait time.Duration) *cooldown {
+ c := newCooldown()
+ c.base, c.penalty, c.max, c.maxWait = base, base, max, maxWait
+ c.jitter = func() time.Duration { return 0 }
+ return c
+}
+
+func trip(c *cooldown) { c.report(false, outcomeRateLimited) }
+
+func checkNoLeak(t *testing.T, before int) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for runtime.NumGoroutine() > before && time.Now().Before(deadline) {
+ time.Sleep(10 * time.Millisecond)
+ }
+ if n := runtime.NumGoroutine(); n > before {
+ t.Fatalf("goroutines: %d before, %d after", before, n)
+ }
+}
+
+// probeScenario starts n waiters on a tripped breaker. Each probe reports the
+// next verdict from verdicts (the last one repeats); non-probe holders report
+// success. It records grants, non-probe requests let through before the
+// successful probe reported (early) and overlapping probes (maxInflight).
+type probeScenario struct {
+ mu sync.Mutex
+ probeGrants []time.Time
+ probeDone []time.Time
+ released time.Time
+ early int
+ inflight int
+ maxInflight int
+ granted int
+}
+
+func runProbeScenario(t *testing.T, c *cooldown, n int, hold time.Duration, verdicts ...outcome) *probeScenario {
+ t.Helper()
+ sc := &probeScenario{}
+ var wg sync.WaitGroup
+ for i := 0; i < n; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ p, _, ok, err := c.acquire(context.Background(), 10*time.Second)
+ if !ok || err != nil {
+ t.Errorf("acquire: ok=%v err=%v", ok, err)
+ return
+ }
+ sc.mu.Lock()
+ sc.granted++
+ if p.probe {
+ sc.probeGrants = append(sc.probeGrants, time.Now())
+ sc.inflight++
+ if sc.inflight > sc.maxInflight {
+ sc.maxInflight = sc.inflight
+ }
+ v := verdicts[min(len(sc.probeGrants)-1, len(verdicts)-1)]
+ sc.mu.Unlock()
+ time.Sleep(hold)
+ sc.mu.Lock()
+ sc.inflight--
+ sc.probeDone = append(sc.probeDone, time.Now())
+ if v == outcomeSuccess {
+ sc.released = time.Now()
+ }
+ sc.mu.Unlock()
+ p.done(v)
+ return
+ }
+ if sc.released.IsZero() {
+ sc.early++
+ }
+ sc.mu.Unlock()
+ p.done(outcomeSuccess)
+ }()
+ }
+ wg.Wait()
+ return sc
+}
+
+func TestBreakerLetsOneProbeThroughThenReleasesAll(t *testing.T) {
+ before := runtime.NumGoroutine()
+ c := newTestBreaker(100*time.Millisecond, time.Second, 10*time.Second)
+ start := time.Now()
+ trip(c)
+ sc := runProbeScenario(t, c, 32, 50*time.Millisecond, outcomeSuccess)
+ if len(sc.probeGrants) != 1 || sc.maxInflight != 1 {
+ t.Fatalf("probes=%d maxInflight=%d, want exactly 1", len(sc.probeGrants), sc.maxInflight)
+ }
+ if sc.early != 0 {
+ t.Fatalf("%d requests went out before the probe succeeded", sc.early)
+ }
+ if sc.granted != 32 {
+ t.Fatalf("granted %d of 32", sc.granted)
+ }
+ if d := sc.probeGrants[0].Sub(start); d < 100*time.Millisecond {
+ t.Fatalf("probe after %s, before the pause ended", d)
+ }
+ if c.tripped || c.penalty != c.base {
+ t.Fatalf("breaker should be closed and reset: tripped=%v penalty=%s", c.tripped, c.penalty)
+ }
+ checkNoLeak(t, before)
+}
+
+func TestBreakerThrottledProbeRearmsPause(t *testing.T) {
+ before := runtime.NumGoroutine()
+ c := newTestBreaker(60*time.Millisecond, time.Second, 10*time.Second)
+ trip(c) // pause 60ms, next 120ms
+ sc := runProbeScenario(t, c, 32, 20*time.Millisecond, outcomeRateLimited, outcomeRateLimited, outcomeSuccess)
+ if len(sc.probeGrants) != 3 || sc.maxInflight != 1 || sc.early != 0 || sc.granted != 32 {
+ t.Fatalf("probes=%d maxInflight=%d early=%d granted=%d", len(sc.probeGrants), sc.maxInflight, sc.early, sc.granted)
+ }
+ for i, want := range []time.Duration{120 * time.Millisecond, 240 * time.Millisecond} {
+ if gap := sc.probeGrants[i+1].Sub(sc.probeDone[i]); gap < want {
+ t.Fatalf("probe %d came %s after the throttled probe, want >= %s", i+2, gap, want)
+ }
+ }
+ checkNoLeak(t, before)
+}
+
+func TestBreakerHandsProbeOverOnFailure(t *testing.T) {
+ before := runtime.NumGoroutine()
+ c := newTestBreaker(50*time.Millisecond, time.Second, 10*time.Second)
+ trip(c)
+ // the first probe fails without a verdict (ctx cancelled / network error)
+ sc := runProbeScenario(t, c, 32, 10*time.Millisecond, outcomeOther, outcomeSuccess)
+ if len(sc.probeGrants) != 2 || sc.maxInflight != 1 || sc.early != 0 || sc.granted != 32 {
+ t.Fatalf("probes=%d maxInflight=%d early=%d granted=%d", len(sc.probeGrants), sc.maxInflight, sc.early, sc.granted)
+ }
+ if gap := sc.probeGrants[1].Sub(sc.probeDone[0]); gap > 30*time.Millisecond {
+ t.Fatalf("probe handed over after %s, want immediately", gap)
+ }
+ checkNoLeak(t, before)
+}
+
+func TestBreakerCancelledProbeHolderHandsOver(t *testing.T) {
+ c := newTestBreaker(20*time.Millisecond, time.Second, 10*time.Second)
+ trip(c)
+ time.Sleep(30 * time.Millisecond)
+ p, _, ok, _ := c.acquire(context.Background(), time.Second)
+ if !ok || !p.probe {
+ t.Fatal("expected to become the probe")
+ }
+ got := make(chan *permit)
+ go func() {
+ q, _, _, _ := c.acquire(context.Background(), time.Second)
+ got <- q
+ }()
+ select {
+ case <-got:
+ t.Fatal("second request must wait while a probe is in flight")
+ case <-time.After(50 * time.Millisecond):
+ }
+ p.done(outcomeOther) // e.g. the probe's ctx was cancelled
+ p.done(outcomeSuccess)
+ select {
+ case q := <-got:
+ if q == nil || !q.probe {
+ t.Fatal("waiter should take over the probe")
+ }
+ if !c.tripped {
+ t.Fatal("extra done() calls must be ignored")
+ }
+ q.done(outcomeSuccess)
+ case <-time.After(time.Second):
+ t.Fatal("probe slot was not handed over")
+ }
+}
+
+func TestBreakerBudgetAndContext(t *testing.T) {
+ before := runtime.NumGoroutine()
+ c := newTestBreaker(10*time.Second, 30*time.Second, 35*time.Second)
+ if p, _, ok, err := c.acquire(context.Background(), time.Second); !ok || err != nil || p.probe {
+ t.Fatal("closed breaker must let requests through")
+ } else {
+ p.done(outcomeSuccess)
+ }
+ trip(c)
+ start := time.Now()
+ if _, _, ok, err := c.acquire(context.Background(), time.Second); ok || err != nil {
+ t.Fatalf("pause beyond budget: ok=%v err=%v", ok, err)
+ }
+ if time.Since(start) > 50*time.Millisecond {
+ t.Fatal("pause beyond budget should fail fast")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+ if _, _, _, err := c.acquire(ctx, time.Minute); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("want ctx error, got %v", err)
+ }
+
+ // a probe that never reports: waiters give up at their budget
+ c2 := newTestBreaker(10*time.Millisecond, time.Second, time.Second)
+ trip(c2)
+ time.Sleep(20 * time.Millisecond)
+ p, _, _, _ := c2.acquire(context.Background(), time.Second)
+ start = time.Now()
+ _, w, ok, err := c2.acquire(context.Background(), 100*time.Millisecond)
+ if ok || err != nil || w < 90*time.Millisecond || time.Since(start) > 500*time.Millisecond {
+ t.Fatalf("waiting on a stuck probe: ok=%v err=%v waited=%s", ok, err, w)
+ }
+ p.done(outcomeSuccess)
+
+ var nilC *cooldown
+ if p, _, ok, err := nilC.acquire(context.Background(), 0); !ok || err != nil {
+ t.Fatal("nil breaker must be a no-op")
+ } else {
+ p.done(outcomeRateLimited)
+ }
+ checkNoLeak(t, before)
+}
+
+func TestBreakerEscalation(t *testing.T) {
+ now := time.Unix(1000, 0)
+ c := newTestBreaker(8*time.Second, 30*time.Second, 35*time.Second)
+ c.now = func() time.Time { return now }
+ pauseLen := func() time.Duration { return c.until.Sub(now) }
+
+ trip(c)
+ if pauseLen() != 8*time.Second {
+ t.Fatalf("first pause %s", pauseLen())
+ }
+ // stragglers sent before the trip must not extend or escalate
+ trip(c)
+ c.report(false, outcomeSuccess)
+ if pauseLen() != 8*time.Second || c.penalty != 16*time.Second || !c.tripped {
+ t.Fatalf("straggler changed state: pause=%s penalty=%s", pauseLen(), c.penalty)
+ }
+ for _, want := range []time.Duration{16 * time.Second, 30 * time.Second, 30 * time.Second} {
+ c.report(true, outcomeRateLimited)
+ if pauseLen() != want {
+ t.Fatalf("pause %s, want %s", pauseLen(), want)
+ }
+ }
+ c.report(true, outcomeSuccess)
+ if c.tripped || c.penalty != 8*time.Second {
+ t.Fatalf("probe success should close and reset: tripped=%v penalty=%s", c.tripped, c.penalty)
+ }
+ trip(c)
+ if pauseLen() != 8*time.Second {
+ t.Fatalf("pause after reset %s", pauseLen())
+ }
+}
+
+// fakeBaidu answers filemanager calls: 31034 for every request while the
+// throttle window is open, then success. Deleting an unknown path gives -9.
+type fakeBaidu struct {
+ mu sync.Mutex
+ openUntil time.Time
+ window time.Duration
+ threshold int // requests allowed before the window opens
+ calls atomic.Int64
+ throttled atomic.Int64
+ missing string
+}
+
+func (f *fakeBaidu) RoundTrip(r *http.Request) (*http.Response, error) {
+ f.calls.Add(1)
+ _ = r.ParseForm()
+ body := `{"errno":0,"info":[{"errno":0,"path":"/x"}],"request_id":1}`
+ f.mu.Lock()
+ now := time.Now()
+ if f.missing != "" && strings.Contains(r.PostForm.Get("filelist"), f.missing) {
+ body = `{"errno":12,"info":[{"errno":-9,"path":"/missing"}],"request_id":1}`
+ } else if now.Before(f.openUntil) {
+ body = rateLimitedBatchBody
+ f.throttled.Add(1)
+ } else if f.threshold > 0 {
+ f.threshold--
+ if f.threshold == 0 {
+ f.openUntil = now.Add(f.window)
+ }
+ }
+ f.mu.Unlock()
+ return &http.Response{
+ StatusCode: 200,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(body)),
+ Request: r,
+ }, nil
+}
+
+func withFakeBaidu(t *testing.T, f *fakeBaidu) {
+ t.Helper()
+ old := base.RestyClient
+ base.RestyClient = resty.New().SetTransport(f)
+ t.Cleanup(func() { base.RestyClient = old })
+}
+
+func testDriver(base, max, maxWait time.Duration) *BaiduNetdisk {
+ d := &BaiduNetdisk{cooldown: newCooldown()}
+ d.cooldown.base, d.cooldown.penalty, d.cooldown.max, d.cooldown.maxWait = base, base, max, maxWait
+ return d
+}
+
+func TestRemoveRidesOutRateLimitWindow(t *testing.T) {
+ f := &fakeBaidu{threshold: 20, window: 400 * time.Millisecond}
+ withFakeBaidu(t, f)
+ d := testDriver(150*time.Millisecond, time.Second, 3*time.Second)
+
+ const n, conc = 200, 32
+ sem := make(chan struct{}, conc)
+ var wg sync.WaitGroup
+ var failed atomic.Int64
+ start := time.Now()
+ for i := 0; i < n; i++ {
+ wg.Add(1)
+ sem <- struct{}{}
+ go func() {
+ defer wg.Done()
+ defer func() { <-sem }()
+ obj := &model.Object{Path: "/x"}
+ if err := d.Remove(context.Background(), obj); err != nil {
+ failed.Add(1)
+ }
+ }()
+ }
+ wg.Wait()
+ t.Logf("%d deletes, %d calls to baidu, %d throttled, %d probes, %s", n, f.calls.Load(), f.throttled.Load(), d.cooldown.probes, time.Since(start))
+ if failed.Load() != 0 {
+ t.Fatalf("%d deletes failed", failed.Load())
+ }
+ if f.throttled.Load() == 0 {
+ t.Fatal("test did not exercise the window")
+ }
+ // only requests already in flight when the breaker opened, plus one probe
+ // per pause, may reach Baidu during the window
+ if max := int64(conc + d.cooldown.probes); f.throttled.Load() > max {
+ t.Fatalf("throttled requests = %d, want <= %d (in-flight + probes)", f.throttled.Load(), max)
+ }
+}
+
+func TestRemoveReturnsRateLimitErrorWhenWindowOutlastsBudget(t *testing.T) {
+ f := &fakeBaidu{openUntil: time.Now().Add(time.Hour)}
+ withFakeBaidu(t, f)
+ d := testDriver(100*time.Millisecond, 200*time.Millisecond, 350*time.Millisecond)
+
+ start := time.Now()
+ err := d.Remove(context.Background(), &model.Object{Path: "/x"})
+ if !errors.Is(err, errs.TemporaryCapacity) {
+ t.Fatalf("want rate-limit error, got %v", err)
+ }
+ if el := time.Since(start); el > 2*time.Second {
+ t.Fatalf("call blocked too long: %s", el)
+ }
+ if c := f.calls.Load(); c != 3 {
+ t.Fatalf("calls = %d, want 3 (initial + 2 retries after cooldowns)", c)
+ }
+}
+
+func TestRemoveTreatsMissingAsSuccess(t *testing.T) {
+ f := &fakeBaidu{missing: "gone"}
+ withFakeBaidu(t, f)
+ d := testDriver(time.Second, time.Second, time.Second)
+ if err := d.Remove(context.Background(), &model.Object{Path: "/gone"}); err != nil {
+ t.Fatalf("remove of missing file: %v", err)
+ }
+ if c := f.calls.Load(); c != 1 {
+ t.Fatalf("missing file should not be retried, calls = %d", c)
+ }
+}
+
+func TestRemoveHonorsContextDuringCooldown(t *testing.T) {
+ f := &fakeBaidu{openUntil: time.Now().Add(time.Hour)}
+ withFakeBaidu(t, f)
+ d := testDriver(10*time.Second, 10*time.Second, 30*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+ start := time.Now()
+ err := d.Remove(ctx, &model.Object{Path: "/x"})
+ if err == nil || time.Since(start) > 2*time.Second {
+ t.Fatalf("err=%v after %s, want quick ctx error", err, time.Since(start))
+ }
+}
diff --git a/drivers/baidu_netdisk/util.go b/drivers/baidu_netdisk/util.go
index 31f3125798..5a807f2f14 100644
--- a/drivers/baidu_netdisk/util.go
+++ b/drivers/baidu_netdisk/util.go
@@ -95,9 +95,26 @@ func (d *BaiduNetdisk) _refreshToken() error {
}
func (d *BaiduNetdisk) request(furl string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) {
- var result []byte
+ return d.requestCtx(context.Background(), furl, method, callback, resp)
+}
+
+func (d *BaiduNetdisk) requestCtx(ctx context.Context, furl string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) {
+ var body []byte
+ var waited time.Duration
err := retry.Do(func() error {
- req := base.RestyClient.R()
+ // Wait for the shared rate-limit breaker, but never longer than the
+ // per-call budget in total.
+ p, w, ok, err := d.cooldown.acquire(ctx, d.cooldown.budget()-waited)
+ waited += w
+ if err != nil {
+ return retry.Unrecoverable(err)
+ }
+ if !ok {
+ return retry.Unrecoverable(&apiError{URL: furl, Errno: errnoRateLimited})
+ }
+ result := outcomeOther
+ defer func() { p.done(result) }()
+ req := base.RestyClient.R().SetContext(ctx)
req.SetQueryParam("access_token", d.AccessToken)
if callback != nil {
callback(req)
@@ -107,34 +124,59 @@ func (d *BaiduNetdisk) request(furl string, method string, callback base.ReqCall
}
res, err := req.Execute(method, furl)
if err != nil {
+ if ctx.Err() != nil {
+ return retry.Unrecoverable(ctx.Err())
+ }
return err
}
log.Debugf("[baidu_netdisk] req: %s, resp: %s", furl, res.String())
- errno := utils.Json.Get(res.Body(), "errno").ToInt()
- if errno != 0 {
- if utils.SliceContains([]int{111, -6, 20016, 20017}, errno) {
- log.Info("[baidu_netdisk] refreshing baidu_netdisk token.")
- err2 := d.refreshToken()
- if err2 != nil {
- return retry.Unrecoverable(err2)
- }
- }
-
- if errno == 31023 && d.DownloadAPI == "crack_video" {
- result = res.Body()
- return nil
+ apiErr := parseAPIError(furl, res.Body())
+ if apiErr == nil {
+ result = outcomeSuccess
+ body = res.Body()
+ return nil
+ }
+ errno := apiErr.Errno
+ if apiErr.RateLimited() {
+ result = outcomeRateLimited
+ return apiErr
+ }
+ // Baidu answered without throttling: as far as the breaker is
+ // concerned this is a success, even if the call itself failed.
+ result = outcomeSuccess
+ if apiErr.NotExist() {
+ // permanent; callers such as Remove decide whether that is fine
+ return retry.Unrecoverable(apiErr)
+ }
+ // NOTE: per the filemanager docs 111 means "an async task is running",
+ // but it has always been treated as token expiry here; kept unchanged.
+ // 20016/20017 are additional token-refresh errnos from upstream (#3232).
+ if utils.SliceContains([]int{111, -6, 20016, 20017}, errno) {
+ log.Info("[baidu_netdisk] refreshing baidu_netdisk token.")
+ err2 := d.refreshToken()
+ if err2 != nil {
+ return retry.Unrecoverable(err2)
}
+ }
- return fmt.Errorf("req: [%s] ,errno: %d, refer to https://pan.baidu.com/union/doc/", furl, errno)
+ if errno == 31023 && d.DownloadAPI == "crack_video" {
+ body = res.Body()
+ return nil
}
- result = res.Body()
- return nil
+
+ return apiErr
},
+ retry.Context(ctx),
retry.LastErrorOnly(true),
retry.Attempts(3),
retry.Delay(time.Second),
- retry.DelayType(retry.BackOffDelay))
- return result, err
+ retry.DelayType(func(n uint, err error, config *retry.Config) time.Duration {
+ if e, ok := asAPIError(err); ok && e.RateLimited() {
+ return 0 // the shared cooldown provides the delay
+ }
+ return retry.BackOffDelay(n, err, config)
+ }))
+ return body, err
}
func (d *BaiduNetdisk) get(pathname string, params map[string]string, resp interface{}) ([]byte, error) {
@@ -144,7 +186,11 @@ func (d *BaiduNetdisk) get(pathname string, params map[string]string, resp inter
}
func (d *BaiduNetdisk) postForm(pathname string, params map[string]string, form map[string]string, resp interface{}) ([]byte, error) {
- return d.request("https://pan.baidu.com/rest/2.0"+pathname, http.MethodPost, func(req *resty.Request) {
+ return d.postFormCtx(context.Background(), pathname, params, form, resp)
+}
+
+func (d *BaiduNetdisk) postFormCtx(ctx context.Context, pathname string, params map[string]string, form map[string]string, resp interface{}) ([]byte, error) {
+ return d.requestCtx(ctx, "https://pan.baidu.com/rest/2.0"+pathname, http.MethodPost, func(req *resty.Request) {
req.SetQueryParams(params)
req.SetFormData(form)
}, resp)
@@ -276,13 +322,13 @@ func (d *BaiduNetdisk) linkCrackVideo(file model.Obj, _ model.LinkArgs) (*model.
}, nil
}
-func (d *BaiduNetdisk) manage(opera string, filelist any) ([]byte, error) {
+func (d *BaiduNetdisk) manage(ctx context.Context, opera string, filelist any) ([]byte, error) {
params := map[string]string{
"method": "filemanager",
"opera": opera,
}
marshal, _ := utils.Json.MarshalToString(filelist)
- return d.postForm("/xpan/file", params, map[string]string{
+ return d.postFormCtx(ctx, "/xpan/file", params, map[string]string{
"async": "0",
"filelist": marshal,
"ondup": "fail",
diff --git a/server/s3/backend.go b/server/s3/backend.go
index de6413ce9b..223cfe5a6b 100644
--- a/server/s3/backend.go
+++ b/server/s3/backend.go
@@ -341,9 +341,13 @@ func (b *s3Backend) DeleteMulti(ctx context.Context, bucketName string, objects
for _, object := range objects {
if err := b.deleteObject(ctx, bucketName, object); err != nil {
log.Errorf("delete object failed: %v", err)
+ code := gofakes3.ErrInternal
+ if gofakes3.HasErrorCode(err, gofakes3.ErrSlowDown) {
+ code = gofakes3.ErrSlowDown
+ }
result.Error = append(result.Error, gofakes3.ErrorResult{
- Code: gofakes3.ErrInternal,
- Message: gofakes3.ErrInternal.Message(),
+ Code: code,
+ Message: code.Message(),
Key: object,
})
} else {
@@ -374,10 +378,15 @@ func (b *s3Backend) deleteObject(ctx context.Context, bucketName, objectName str
// S3 does not report an error when attemping to delete a key that does not exist, so
// we need to skip IsNotExist errors.
if _, err := fs.Get(context.WithValue(ctx, conf.MetaKey, fmeta), fp, &fs.GetArgs{}); err != nil && !errs.IsObjectNotFound(err) {
- return err
+ return mapBackendError(err)
}
- fs.Remove(ctx, fp)
+ // Report failures (op.Remove already treats a missing object as success) so
+ // clients retry instead of leaving the object behind; rate limiting becomes
+ // 503 SlowDown, which S3 clients retry with backoff.
+ if err := fs.Remove(ctx, fp); err != nil {
+ return mapBackendError(err)
+ }
return nil
}
diff --git a/server/s3/delete_test.go b/server/s3/delete_test.go
new file mode 100644
index 0000000000..56d942602b
--- /dev/null
+++ b/server/s3/delete_test.go
@@ -0,0 +1,164 @@
+package s3
+
+import (
+ "context"
+ "encoding/json"
+ "encoding/xml"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/OpenListTeam/OpenList/v4/drivers/local"
+ "github.com/OpenListTeam/OpenList/v4/internal/conf"
+ "github.com/OpenListTeam/OpenList/v4/internal/driver"
+ "github.com/OpenListTeam/OpenList/v4/internal/errs"
+ "github.com/OpenListTeam/OpenList/v4/internal/model"
+ "github.com/OpenListTeam/OpenList/v4/internal/op"
+ "github.com/OpenListTeam/gofakes3"
+)
+
+const removeErrDriverName = "S3RemoveErrLocal"
+
+// removeErr is the error the fixture driver's Remove returns (nil = delete normally).
+var removeErr error
+
+type removeErrDriver struct{ local.Local }
+
+func (d *removeErrDriver) Config() driver.Config {
+ c := d.Local.Config()
+ c.Name = removeErrDriverName
+ return c
+}
+
+func (d *removeErrDriver) Remove(ctx context.Context, obj model.Obj) error {
+ if removeErr != nil {
+ return removeErr
+ }
+ return d.Local.Remove(ctx, obj)
+}
+
+func setupDeleteServer(t *testing.T) (*httptest.Server, string) {
+ t.Helper()
+ ctx := context.Background()
+ op.RegisterDriver(func() driver.Driver { return &removeErrDriver{} })
+ root := t.TempDir()
+ addition, _ := json.Marshal(struct {
+ RootFolderPath string `json:"root_folder_path"`
+ }{RootFolderPath: root})
+ mount := "/" + sanitizeTestName(t.Name())
+ id, err := op.CreateStorage(ctx, model.Storage{Driver: removeErrDriverName, MountPath: mount, Addition: string(addition)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = op.DeleteStorageById(ctx, id) })
+ if err := op.SaveSettingItem(&model.SettingItem{Key: conf.S3Buckets, Value: `[{"name":"del","path":"` + mount + `"}]`}); err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(gofakes3.New(newBackend(), gofakes3.WithoutVersioning()).Server())
+ t.Cleanup(srv.Close)
+ t.Cleanup(func() { removeErr = nil })
+ return srv, root
+}
+
+func doReq(t *testing.T, method, url, body string) (*http.Response, string) {
+ t.Helper()
+ req, _ := http.NewRequest(method, url, strings.NewReader(body))
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ b, _ := io.ReadAll(resp.Body)
+ return resp, string(b)
+}
+
+func TestDeleteObjectReportsRemoveErrors(t *testing.T) {
+ srv, root := setupDeleteServer(t)
+ write := func(name string) string {
+ p := filepath.Join(root, name)
+ if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return p
+ }
+ exists := func(p string) bool { _, err := os.Stat(p); return err == nil }
+
+ cases := []struct {
+ name string
+ err error
+ wantStatus int
+ wantCode gofakes3.ErrorCode
+ wantGone bool
+ }{
+ {"rate limited", errs.NewErr(errs.TemporaryCapacity, "baidu 31034"), http.StatusServiceUnavailable, gofakes3.ErrSlowDown, false},
+ {"other error", errors.New("baidu errno 12"), http.StatusInternalServerError, gofakes3.ErrInternal, false},
+ {"success", nil, http.StatusNoContent, "", true},
+ }
+ for i, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ name := "obj" + string(rune('a'+i))
+ p := write(name)
+ removeErr = c.err
+ resp, body := doReq(t, http.MethodDelete, srv.URL+"/del/"+name, "")
+ if resp.StatusCode != c.wantStatus {
+ t.Fatalf("status = %d, want %d (%s)", resp.StatusCode, c.wantStatus, body)
+ }
+ if c.wantCode != "" && !strings.Contains(body, ""+string(c.wantCode)+"") {
+ t.Fatalf("body %q lacks code %s", body, c.wantCode)
+ }
+ if exists(p) == c.wantGone {
+ t.Fatalf("file exists=%v, want gone=%v", exists(p), c.wantGone)
+ }
+ })
+ }
+
+ t.Run("missing key is success", func(t *testing.T) {
+ removeErr = errors.New("must not be called")
+ resp, body := doReq(t, http.MethodDelete, srv.URL+"/del/never-existed", "")
+ if resp.StatusCode != http.StatusNoContent {
+ t.Fatalf("status = %d (%s)", resp.StatusCode, body)
+ }
+ })
+}
+
+func TestDeleteMultiReportsPerKeyErrors(t *testing.T) {
+ srv, root := setupDeleteServer(t)
+ for _, n := range []string{"k1", "k2"} {
+ if err := os.WriteFile(filepath.Join(root, n), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ removeErr = errs.NewErr(errs.TemporaryCapacity, "baidu 31034")
+ resp, body := doReq(t, http.MethodPost, srv.URL+"/del?delete",
+ ``)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d (%s)", resp.StatusCode, body)
+ }
+ var out struct {
+ Deleted []struct{ Key string } `xml:"Deleted"`
+ Error []struct {
+ Key string
+ Code string
+ } `xml:"Error"`
+ }
+ if err := xml.Unmarshal([]byte(body), &out); err != nil {
+ t.Fatal(err)
+ }
+ if len(out.Error) != 1 || out.Error[0].Key != "k1" || out.Error[0].Code != string(gofakes3.ErrSlowDown) {
+ t.Fatalf("errors = %+v, want k1 SlowDown (%s)", out.Error, body)
+ }
+ if len(out.Deleted) != 1 || out.Deleted[0].Key != "gone" {
+ t.Fatalf("deleted = %+v, want [gone]", out.Deleted)
+ }
+
+ removeErr = errors.New("boom")
+ _, body = doReq(t, http.MethodPost, srv.URL+"/del?delete", ``)
+ if !strings.Contains(body, "InternalError") {
+ t.Fatalf("want InternalError for k2: %s", body)
+ }
+}