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:
Lucas WintherandClaude Opus 5.5 committed 2026-11-12 14:40:13 +01:00
1 parent 788a711dc9
commit 5dbf5b04c6
7 files changed
+96 -7

No files matched your search

+10 -3
View File
@@ -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
+7
View File
@@ -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):
+8 -1
View File
@@ -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)
+5 -1
View File
@@ -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)
+30 -1
View File
@@ -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) {
+21
View File
@@ -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