Files
rclone/backend/zoho/throttle_test.go
Erol Ozcan 4ab4b952be zoho: honour Retry-After header on 429
Zoho WorkDrive now sends a Retry-After header on 429 (it did not when
the backend was written). Waiting the hard-coded 60s retried too early
when the server asked for more (Retry-After: 299 is common) and the
penalty escalated (observed 84s -> 239s). Honour the header plus a 1s
margin - retrying at exactly Retry-After still finds an empty token
bucket and burns ~16 immediate 429s - and keep 60s as the fallback
when the header is absent.

shouldRetry becomes a method on *Fs so the retry decision has access
to the remote's state; later commits build on this.

See #9570
2026-07-05 12:28:52 +01:00

81 lines
2.6 KiB
Go

package zoho
import (
"context"
"net/http"
"testing"
"time"
"github.com/rclone/rclone/lib/pacer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newTestFs returns a bare *Fs so the 429/retry logic in shouldRetry can be
// exercised in isolation. No network or pacer is involved.
func newTestFs() *Fs {
return &Fs{}
}
func TestShouldRetry(t *testing.T) {
ctx := context.Background()
// A 429 with a numeric Retry-After is honoured, plus retryAfterMargin.
t.Run("429 honours Retry-After", func(t *testing.T) {
f := newTestFs()
resp := &http.Response{StatusCode: 429, Header: http.Header{"Retry-After": {"5"}}}
retry, err := f.shouldRetry(ctx, resp, assert.AnError)
assert.True(t, retry)
wait, ok := pacer.IsRetryAfter(err)
require.True(t, ok)
assert.Equal(t, 5*time.Second+retryAfterMargin, wait)
})
// A 429 without a Retry-After header falls back to 60s + margin.
t.Run("429 without Retry-After falls back to 60s", func(t *testing.T) {
f := newTestFs()
resp := &http.Response{StatusCode: 429, Header: http.Header{}}
retry, err := f.shouldRetry(ctx, resp, assert.AnError)
assert.True(t, retry)
wait, ok := pacer.IsRetryAfter(err)
require.True(t, ok)
assert.Equal(t, 60*time.Second+retryAfterMargin, wait)
})
// An unparseable Retry-After is ignored in favour of the 60s fallback.
t.Run("429 with unparseable Retry-After falls back", func(t *testing.T) {
f := newTestFs()
resp := &http.Response{StatusCode: 429, Header: http.Header{"Retry-After": {"soon"}}}
retry, err := f.shouldRetry(ctx, resp, assert.AnError)
assert.True(t, retry)
wait, ok := pacer.IsRetryAfter(err)
require.True(t, ok)
assert.Equal(t, 60*time.Second+retryAfterMargin, wait)
})
// A missing OAuth scope is fatal and must not be retried.
t.Run("401 missing scope aborts", func(t *testing.T) {
f := newTestFs()
resp := &http.Response{StatusCode: 401, Status: "401 INVALID_OAUTHSCOPE"}
retry, _ := f.shouldRetry(ctx, resp, assert.AnError)
assert.False(t, retry)
})
// An expired OAuth token is retried so the token can refresh.
t.Run("401 expired token retries", func(t *testing.T) {
f := newTestFs()
resp := &http.Response{StatusCode: 401, Header: http.Header{"Www-Authenticate": {`Bearer error="expired_token"`}}}
retry, _ := f.shouldRetry(ctx, resp, assert.AnError)
assert.True(t, retry)
})
// A cancelled context is never retried.
t.Run("cancelled context aborts", func(t *testing.T) {
f := newTestFs()
cctx, cancel := context.WithCancel(ctx)
cancel()
retry, _ := f.shouldRetry(cctx, nil, nil)
assert.False(t, retry)
})
}