One refused sign-in, not five: the chargers poll no longer locks the account
A chargers poll asks four cloud views. Each called apiRequest, each found no token, and each ran its own login — so a login Anker refuses was offered four times in one poll, and the next poll spent the fifth. Five is what disables the account for ten minutes, which is how "code 26161: Failed to request." turned into "your account has been disabled" on the very next attempt. The plugin now remembers a refused login instead of repeating it: the failure is cached and replayed to every caller until a backoff window passes — a minute at first, doubling to fifteen, or the full ten minutes when Anker says it has already locked the account (code 10019). New credentials clear it, so a fixed password is tried at once. chargerInventory signs in once up front. A login the cloud refuses is not four views failing, so it is reported as itself rather than as three warnings with the lockout notice buried in the last one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
423bc2ab16
commit
c1aee0fac1
@@ -158,10 +158,19 @@ type Plugin struct {
|
||||
password string
|
||||
countryId string
|
||||
|
||||
mu sync.Mutex // guards tok and the login flow
|
||||
mu sync.Mutex // guards tok, the login backoff and the login flow
|
||||
tok *tokenInfo
|
||||
apiBase string
|
||||
client *http.Client
|
||||
|
||||
// A refused login is remembered rather than repeated. Anker disables an
|
||||
// account for ten minutes after five failed sign-ins, and a single chargers
|
||||
// poll asks four cloud views — each of which, holding no token, would try to
|
||||
// sign in for itself. Without this the poll that discovers a bad login is
|
||||
// also the one that locks the account.
|
||||
loginErr error
|
||||
loginRetryAt time.Time
|
||||
loginFails int
|
||||
}
|
||||
|
||||
// Descriptor returns the plugin's static metadata for the admin panel.
|
||||
@@ -232,8 +241,10 @@ func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
||||
} else {
|
||||
p.apiBase = serverEU
|
||||
}
|
||||
// Config change invalidates any cached token.
|
||||
// Config change invalidates any cached token, and clears the login backoff:
|
||||
// new credentials deserve an attempt straight away.
|
||||
p.tok = nil
|
||||
p.loginErr, p.loginRetryAt, p.loginFails = nil, time.Time{}, 0
|
||||
p.client = &http.Client{Timeout: 30 * time.Second}
|
||||
return nil
|
||||
}
|
||||
@@ -622,6 +633,57 @@ func chargerModeOptions(mode, statusDesc string) []string {
|
||||
|
||||
// ---- token management --------------------------------------------------------
|
||||
|
||||
// Login backoff. A refused sign-in is cached and replayed to every caller until
|
||||
// its window passes, so credentials Anker rejects are offered once, not once per
|
||||
// request.
|
||||
const (
|
||||
loginRetryBase = 1 * time.Minute
|
||||
loginRetryMax = 15 * time.Minute
|
||||
|
||||
// codeSignInLocked is what Anker answers once it has already disabled the
|
||||
// account for repeated failures; it states a ten-minute penalty, so sit the
|
||||
// penalty out rather than spending attempts against a door that is shut.
|
||||
codeSignInLocked = 10019
|
||||
loginLockoutWait = 10 * time.Minute
|
||||
)
|
||||
|
||||
// loginRejected is the login endpoint answering with a non-zero API code. The
|
||||
// code is kept so an already-locked account can be told from an ordinary
|
||||
// refusal.
|
||||
type loginRejected struct {
|
||||
code int
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *loginRejected) Error() string {
|
||||
return fmt.Sprintf("anker-solix: login rejected (code %d): %s", e.code, e.msg)
|
||||
}
|
||||
|
||||
// loginFailure wraps whatever made a login attempt fail, together with the time
|
||||
// before which no further attempt is made.
|
||||
type loginFailure struct {
|
||||
err error
|
||||
retryAt time.Time
|
||||
}
|
||||
|
||||
func (e *loginFailure) Error() string {
|
||||
wait := time.Until(e.retryAt).Round(time.Second)
|
||||
if wait < 0 {
|
||||
wait = 0
|
||||
}
|
||||
return fmt.Sprintf("%s (not retrying for %s)", e.err, wait)
|
||||
}
|
||||
|
||||
func (e *loginFailure) Unwrap() error { return e.err }
|
||||
|
||||
// isLoginFailure reports whether err came from the login exchange rather than
|
||||
// from the endpoint the caller was asking for — the difference between "this
|
||||
// account cannot sign in" and "this one view said no".
|
||||
func isLoginFailure(err error) bool {
|
||||
var lf *loginFailure
|
||||
return errors.As(err, &lf)
|
||||
}
|
||||
|
||||
// ensureToken guarantees a non-expired auth token, logging in as needed.
|
||||
// Serialized by p.mu so concurrent requests trigger at most one login.
|
||||
func (p *Plugin) ensureToken(ctx context.Context) (tokenInfo, error) {
|
||||
@@ -631,12 +693,43 @@ func (p *Plugin) ensureToken(ctx context.Context) (tokenInfo, error) {
|
||||
if p.tok != nil && time.Now().Before(p.tok.expiration) {
|
||||
return *p.tok, nil
|
||||
}
|
||||
if err := p.login(ctx); err != nil {
|
||||
if err := p.attemptLogin(ctx); err != nil {
|
||||
return tokenInfo{}, err
|
||||
}
|
||||
return *p.tok, nil
|
||||
}
|
||||
|
||||
// attemptLogin logs in at most once per backoff window, handing every caller in
|
||||
// between the failure that opened it. Caller holds p.mu.
|
||||
func (p *Plugin) attemptLogin(ctx context.Context) error {
|
||||
if p.loginErr != nil && time.Now().Before(p.loginRetryAt) {
|
||||
return p.loginErr
|
||||
}
|
||||
p.noteLogin(p.login(ctx))
|
||||
return p.loginErr
|
||||
}
|
||||
|
||||
// noteLogin records the outcome of a login attempt and, on failure, how long to
|
||||
// leave the account alone: one Anker has already disabled gets the full penalty,
|
||||
// anything else backs off exponentially up to loginRetryMax. Caller holds p.mu.
|
||||
func (p *Plugin) noteLogin(err error) {
|
||||
if err == nil {
|
||||
p.loginErr, p.loginRetryAt, p.loginFails = nil, time.Time{}, 0
|
||||
return
|
||||
}
|
||||
p.loginFails++
|
||||
wait := loginRetryBase << min(p.loginFails-1, 8)
|
||||
if wait > loginRetryMax {
|
||||
wait = loginRetryMax
|
||||
}
|
||||
var rejected *loginRejected
|
||||
if errors.As(err, &rejected) && rejected.code == codeSignInLocked && wait < loginLockoutWait {
|
||||
wait = loginLockoutWait
|
||||
}
|
||||
p.loginRetryAt = time.Now().Add(wait)
|
||||
p.loginErr = &loginFailure{err: err, retryAt: p.loginRetryAt}
|
||||
}
|
||||
|
||||
// login performs the ECDH + AES password exchange against passport/login and
|
||||
// stores the returned token. Caller holds p.mu.
|
||||
func (p *Plugin) login(ctx context.Context) error {
|
||||
@@ -704,7 +797,7 @@ func (p *Plugin) storeToken(body []byte) error {
|
||||
return fmt.Errorf("anker-solix: decode login response: %w", err)
|
||||
}
|
||||
if env.Code != 0 {
|
||||
return fmt.Errorf("anker-solix: login rejected (code %d): %s", env.Code, shorten(env.Msg))
|
||||
return &loginRejected{code: env.Code, msg: shorten(env.Msg)}
|
||||
}
|
||||
if env.Data.AuthToken == "" || env.Data.UserID == "" {
|
||||
return errors.New("anker-solix: login response missing auth_token or user_id")
|
||||
@@ -749,7 +842,7 @@ func (p *Plugin) apiRequest(ctx context.Context, endpoint string, payload map[st
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden || isAuthCode(body) {
|
||||
p.mu.Lock()
|
||||
p.tok = nil
|
||||
lerr := p.login(ctx)
|
||||
lerr := p.attemptLogin(ctx)
|
||||
var newTok tokenInfo
|
||||
if lerr == nil {
|
||||
newTok = *p.tok
|
||||
|
||||
@@ -216,6 +216,14 @@ func (p *Plugin) accountChargers(ctx context.Context) (json.RawMessage, error) {
|
||||
// described at the top of this file. A view that fails is recorded as a warning
|
||||
// and the others still answer; only losing all of them is an error.
|
||||
func (p *Plugin) chargerInventory(ctx context.Context) (chargersDoc, error) {
|
||||
// Sign in once, up front. Every view below needs the same token, and a login
|
||||
// Anker refuses is not one view failing — it is all of them, retried in turn,
|
||||
// five of which disable the account for ten minutes. Fail on the login
|
||||
// itself, and say so instead of blaming the views.
|
||||
if _, err := p.ensureToken(ctx); err != nil {
|
||||
return chargersDoc{}, err
|
||||
}
|
||||
|
||||
inv := newInventory()
|
||||
var warnings []string
|
||||
views, failed := 0, 0
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package ankersolix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestChargerInventoryLoginRefusedOnce is the regression: one chargers poll asks
|
||||
// four cloud views, and each of them holding no token used to try its own login.
|
||||
// Anker disables the account after five refusals, so the poll that found a bad
|
||||
// login also locked the account out for ten minutes. The poll must spend exactly
|
||||
// one sign-in, and the next one must spend none until the backoff expires.
|
||||
func TestChargerInventoryLoginRefusedOnce(t *testing.T) {
|
||||
logins := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, epLogin) {
|
||||
t.Errorf("unexpected request to %s with no token", r.URL.Path)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
logins++
|
||||
_, _ = w.Write([]byte(`{"code":26161,"msg":"Failed to request."}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := &Plugin{}
|
||||
if err := p.Init(context.Background(), map[string]string{"email": "u@example.com", "password": "pw"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.apiBase = srv.URL
|
||||
|
||||
_, err := p.chargerInventory(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected the refused login to fail the inventory")
|
||||
}
|
||||
if !isLoginFailure(err) {
|
||||
t.Errorf("error should be recognisable as a login failure, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "26161") {
|
||||
t.Errorf("error should carry Anker's code, got %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "every cloud view failed") {
|
||||
t.Errorf("a refused login is not four failed views, got %v", err)
|
||||
}
|
||||
if logins != 1 {
|
||||
t.Fatalf("login attempts = %d, want 1", logins)
|
||||
}
|
||||
|
||||
if _, err := p.chargerInventory(context.Background()); err == nil {
|
||||
t.Fatal("expected the cached failure to be replayed")
|
||||
}
|
||||
if logins != 1 {
|
||||
t.Fatalf("login attempts after a second poll = %d, want 1", logins)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginBackoffWindows checks how long each kind of refusal buys: an ordinary
|
||||
// one starts at loginRetryBase and doubles, an account Anker has already
|
||||
// disabled gets the full penalty it asked for.
|
||||
func TestLoginBackoffWindows(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
|
||||
p.noteLogin(errors.New("boom"))
|
||||
if wait := time.Until(p.loginRetryAt); wait > loginRetryBase || wait < loginRetryBase-time.Second {
|
||||
t.Errorf("first failure waits %s, want ~%s", wait, loginRetryBase)
|
||||
}
|
||||
p.noteLogin(errors.New("boom"))
|
||||
if wait := time.Until(p.loginRetryAt); wait <= loginRetryBase {
|
||||
t.Errorf("second failure waits %s, want more than %s", wait, loginRetryBase)
|
||||
}
|
||||
|
||||
p.noteLogin(&loginRejected{code: codeSignInLocked, msg: "account disabled for 10 minutes"})
|
||||
if wait := time.Until(p.loginRetryAt); wait < loginLockoutWait-time.Second {
|
||||
t.Errorf("locked account waits %s, want at least %s", wait, loginLockoutWait)
|
||||
}
|
||||
|
||||
p.noteLogin(nil)
|
||||
if p.loginErr != nil || p.loginFails != 0 || !p.loginRetryAt.IsZero() {
|
||||
t.Error("a successful login must clear the backoff")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginBackoffCappedAndCleared: the wait never exceeds loginRetryMax however
|
||||
// long the outage lasts, and new credentials get an attempt straight away.
|
||||
func TestLoginBackoffCappedAndCleared(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
for i := 0; i < 20; i++ {
|
||||
p.noteLogin(errors.New("boom"))
|
||||
}
|
||||
if wait := time.Until(p.loginRetryAt); wait > loginRetryMax {
|
||||
t.Errorf("wait %s exceeds the %s cap", wait, loginRetryMax)
|
||||
}
|
||||
|
||||
if err := p.Init(context.Background(), map[string]string{"email": "u@example.com", "password": "new"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.loginErr != nil || p.loginFails != 0 {
|
||||
t.Error("re-configuring must clear the backoff so new credentials are tried")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user