protondrive: stop telling users to upgrade rclone on unrelated Proton errors
Proton's generic "requirements not met" code 2000, which it returns for a node changed since it was read (as when a lost rename response is resent), aborted the whole run with "upgrade rclone". It is now an ordinary error that isn't retried. Code 2028 (a feature disabled, rate-limited or blocked) is retried, and the codes Proton actually uses for a rejected or missing app version, 5003 and 5001, now give the upgrade message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
788a711dc9
commit
5dbf5b04c6
7 files changed
+96
-7
No files matched your search
@@ -34,9 +34,16 @@ const (
|
||||
CodeInsufficientShareJoinedQuota = 200602
|
||||
CodeInsufficientBookmarksQuota = 200800
|
||||
|
||||
// CodeAppVersionBad is returned when a feature is disabled,
|
||||
// rate-limited or blocked for abuse, including a blocked app version.
|
||||
CodeAppVersionBad = 2028
|
||||
// CodeFeatureBlocked is returned when a feature is disabled,
|
||||
// rate-limited or blocked because of abuse (driveTypes.ts).
|
||||
CodeFeatureBlocked = 2028
|
||||
// CodeAppVersionMissing is returned when the request has no app
|
||||
// version (go-proton-api AppVersionMissingCode).
|
||||
CodeAppVersionMissing = 5001
|
||||
// CodeAppVersionBad is returned when the app version is no longer
|
||||
// accepted: "You need to update this app" (coreTypes.ts,
|
||||
// go-proton-api AppVersionBadCode).
|
||||
CodeAppVersionBad = 5003
|
||||
// CodeInvalidSection is returned when a request header such as
|
||||
// x-pm-appversion is malformed.
|
||||
CodeInvalidSection = 2064
|
||||
|
||||
@@ -276,6 +276,13 @@ func (c *client) shouldRetry(ctx context.Context, resp *http.Response, err error
|
||||
return true, pacer.RetryAfterError(err, retryAfter(resp))
|
||||
case status == http.StatusRequestTimeout || status >= 500:
|
||||
return true, err
|
||||
case errors.As(err, &apiErr) && apiErr.Code == api.CodeFeatureBlocked:
|
||||
// Proton's code for a feature disabled, rate-limited or
|
||||
// blocked: it may pass.
|
||||
if resp != nil && resp.Header.Get("Retry-After") != "" {
|
||||
return true, pacer.RetryAfterError(err, retryAfter(resp))
|
||||
}
|
||||
return true, err
|
||||
case errors.As(err, &apiErr) && apiErr.Status >= 400 && apiErr.Status < 500:
|
||||
return false, err
|
||||
case errors.Is(err, errSessionExpired), errors.Is(err, errCaptcha), errors.Is(err, errNotSignedIn):
|
||||
|
||||
@@ -409,6 +409,7 @@ func TestClientShouldRetry(t *testing.T) {
|
||||
name string
|
||||
ctx context.Context
|
||||
status int // HTTP status of the response and the *api.Error, if not zero
|
||||
code int // Proton code of the *api.Error; 2000 if zero
|
||||
header string // Retry-After of the response
|
||||
err error // error if status is zero
|
||||
retry bool
|
||||
@@ -429,6 +430,8 @@ func TestClientShouldRetry(t *testing.T) {
|
||||
{name: "500", status: 500, retry: true},
|
||||
{name: "503", status: 503, retry: true},
|
||||
{name: "422", status: 422},
|
||||
{name: "422 code 2028", status: 422, code: api.CodeFeatureBlocked, retry: true},
|
||||
{name: "422 code 2028 with Retry-After", status: 422, code: api.CodeFeatureBlocked, header: "4", retry: true, retryAfter: 4 * time.Second},
|
||||
{name: "404", status: 404},
|
||||
{name: "401 api error without response", err: &api.Error{Status: 401}},
|
||||
{name: "session expired", err: fserrors.FatalError(errSessionExpired)},
|
||||
@@ -448,7 +451,11 @@ func TestClientShouldRetry(t *testing.T) {
|
||||
if test.header != "" {
|
||||
resp.Header.Set("Retry-After", test.header)
|
||||
}
|
||||
err = &api.Error{Status: test.status, Code: 2000, Message: http.StatusText(test.status)}
|
||||
code := test.code
|
||||
if code == 0 {
|
||||
code = api.CodeInvalidRequirements
|
||||
}
|
||||
err = &api.Error{Status: test.status, Code: code, Message: http.StatusText(test.status)}
|
||||
}
|
||||
retry, gotErr := c.shouldRetry(ctx, resp, err)
|
||||
assert.Equal(t, test.retry, retry)
|
||||
|
||||
@@ -393,7 +393,11 @@ func translateError(err error, op errorOp) error {
|
||||
case api.CodeInsufficientQuota, api.CodeInsufficientSpace, api.CodeMaxFileSizeForFreeUser,
|
||||
api.CodeMaxPublicEditModeForFreeUser, api.CodeTooManyChildren, api.CodeNestingTooDeep:
|
||||
return fserrors.NoRetryError(err)
|
||||
case api.CodeInvalidRequirements, api.CodeAppVersionBad:
|
||||
case api.CodeInvalidRequirements:
|
||||
// Unmet requirements, such as a node changed since it was
|
||||
// read: retrying the same request won't help.
|
||||
return fserrors.NoRetryError(err)
|
||||
case api.CodeAppVersionMissing, api.CodeAppVersionBad:
|
||||
return fserrors.FatalError(fmt.Errorf("this version of rclone is no longer accepted by Proton: upgrade rclone: %w", err))
|
||||
}
|
||||
return err
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestTranslateError(t *testing.T) {
|
||||
assert.Contains(t, got.Error(), "proton says no")
|
||||
})
|
||||
}
|
||||
for _, code := range []int{api.CodeInvalidRequirements, api.CodeAppVersionBad} {
|
||||
for _, code := range []int{api.CodeAppVersionMissing, api.CodeAppVersionBad} {
|
||||
t.Run(fmt.Sprintf("code %d fatal", code), func(t *testing.T) {
|
||||
got := translateError(apiErr(code), opObject)
|
||||
assert.True(t, fserrors.IsFatalError(got))
|
||||
@@ -82,6 +82,20 @@ func TestTranslateError(t *testing.T) {
|
||||
assert.Contains(t, got.Error(), "proton says no")
|
||||
})
|
||||
}
|
||||
t.Run("code 2000 no retry", func(t *testing.T) {
|
||||
got := translateError(apiErr(api.CodeInvalidRequirements), opMove)
|
||||
assert.True(t, fserrors.IsNoRetryError(got))
|
||||
assert.False(t, fserrors.IsFatalError(got))
|
||||
assert.NotContains(t, got.Error(), "upgrade rclone")
|
||||
assert.Contains(t, got.Error(), "proton says no")
|
||||
})
|
||||
t.Run("code 2028 not fatal", func(t *testing.T) {
|
||||
got := translateError(apiErr(api.CodeFeatureBlocked), opObject)
|
||||
assert.False(t, fserrors.IsFatalError(got))
|
||||
assert.False(t, fserrors.IsNoRetryError(got))
|
||||
assert.NotContains(t, got.Error(), "upgrade rclone")
|
||||
assert.Contains(t, got.Error(), "proton says no")
|
||||
})
|
||||
for _, err := range []error{errIntegrity, errUploadMismatch, errUploadSuperseded} {
|
||||
t.Run(err.Error()+" no retry", func(t *testing.T) {
|
||||
got := translateError(fmt.Errorf("op: %w", err), opObject)
|
||||
|
||||
@@ -527,13 +527,42 @@ func TestRegressionCaptcha(t *testing.T) {
|
||||
func TestRegressionAppVersionRejected(t *testing.T) {
|
||||
remote, srv, _ := setupFakeRemote(t)
|
||||
f := newFakeFs(t, remote, "")
|
||||
srv.Inject(protontest.Fault{Method: "GET", Pattern: "/drive/v2/volumes/{volumeID}/folders/{linkID}/children", Status: http.StatusBadRequest, Code: 2028})
|
||||
srv.Inject(protontest.Fault{Method: "GET", Pattern: "/drive/v2/volumes/{volumeID}/folders/{linkID}/children", Status: http.StatusBadRequest, Code: 5003})
|
||||
_, err := f.List(context.Background(), "")
|
||||
require.Error(t, err)
|
||||
assert.True(t, fserrors.IsFatalError(err), "not fatal: %v", err)
|
||||
assert.Contains(t, err.Error(), "upgrade rclone")
|
||||
}
|
||||
|
||||
// TestRegressionFeatureBlockedRetried checks Proton's code for a
|
||||
// feature disabled, rate-limited or blocked is retried, not fatal.
|
||||
func TestRegressionFeatureBlockedRetried(t *testing.T) {
|
||||
remote, srv, u := setupFakeRemote(t)
|
||||
u.SeedFile(u.RootID(), "a.txt", []byte("a"), time.Now())
|
||||
f := newFakeFs(t, remote, "")
|
||||
srv.Inject(protontest.Fault{Method: "GET", Pattern: "/drive/v2/volumes/{volumeID}/folders/{linkID}/children", Times: 2, Status: http.StatusUnprocessableEntity, Code: 2028})
|
||||
entries, err := f.List(context.Background(), "")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, entries, 1)
|
||||
}
|
||||
|
||||
// TestRegressionRenameResendNotFatal checks a rename whose response was
|
||||
// lost, so that its resend fails Proton's stale-state check, doesn't
|
||||
// abort the whole run as an "upgrade rclone" error.
|
||||
func TestRegressionRenameResendNotFatal(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
remote, srv, u := setupFakeRemote(t)
|
||||
u.SeedFile(u.RootID(), "old.txt", []byte("x"), time.Now())
|
||||
f := newFakeFs(t, remote, "")
|
||||
o, err := f.NewObject(ctx, "old.txt")
|
||||
require.NoError(t, err)
|
||||
srv.Inject(protontest.Fault{Method: "PUT", Pattern: "/drive/v2/volumes/{volumeID}/links/{linkID}/rename", Status: http.StatusBadGateway, AfterHandler: true, Times: 1})
|
||||
_, err = f.Features().Move(ctx, o, "new.txt")
|
||||
require.Error(t, err, "the resend saw the rename already made")
|
||||
assert.False(t, fserrors.IsFatalError(err), "fatal: %v", err)
|
||||
assert.NotContains(t, err.Error(), "upgrade rclone")
|
||||
}
|
||||
|
||||
// TestRegressionQuotaExceeded checks running out of space is reported
|
||||
// with Proton's message and not retried.
|
||||
func TestRegressionQuotaExceeded(t *testing.T) {
|
||||
|
||||
@@ -5,6 +5,27 @@ it with a new one that links back. One entry per decision a future maintainer
|
||||
would otherwise have to reverse-engineer.
|
||||
No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry.
|
||||
|
||||
## 2026-11-12 - Proton Drive maps only the app-version codes to "upgrade rclone"
|
||||
|
||||
**Status:** accepted (handoff ruling 30). Supersedes spec 6.6's
|
||||
"2000/2028 ... to a fatal error telling the user to upgrade rclone".
|
||||
**Context:** 2000 is Proton's generic "requirements not met" code, which
|
||||
the server returns for a stale `OriginalHash` or `CurrentRevisionID`, as
|
||||
after a rename whose response was lost and which was resent. Mapping it to
|
||||
a fatal "upgrade rclone" error aborted whole runs over a rename that had
|
||||
succeeded. 2028 means a feature is disabled, rate-limited or blocked for
|
||||
abuse (`driveTypes.ts`). The code Proton returns for a rejected app
|
||||
version is 5003 ("You need to update this app", `coreTypes.ts`), and 5001
|
||||
for a missing one (go-proton-api), neither of which was mapped.
|
||||
**Decision:** 5001 and 5003 are a fatal error telling the user to upgrade
|
||||
rclone. 2000 is a no-retry error keeping Proton's message. 2028 is retried
|
||||
through the pacer (honouring `Retry-After` when sent), then returned as
|
||||
it is. 2064 (malformed header) is left unmapped.
|
||||
**Alternatives:** Keeping the spec's mapping (false fatal errors);
|
||||
treating 2028 as a no-retry error (fails at once on rate limiting).
|
||||
**Consequences:** A feature Proton disables outright costs the low-level
|
||||
retries before its error is returned.
|
||||
|
||||
## 2026-11-12 - Proton Drive caps the Argon2 cost of node and share keys
|
||||
|
||||
**Status:** accepted
|
||||
|
||||
Reference in new issue
Block a user