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:
tajniak81
2026-08-31 22:54:24 +02:00
co-authored by Claude Opus 5
parent 423bc2ab16
commit c1aee0fac1
3 changed files with 211 additions and 5 deletions
@@ -158,10 +158,19 @@ type Plugin struct {
password string password string
countryId 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 tok *tokenInfo
apiBase string apiBase string
client *http.Client 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. // 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 { } else {
p.apiBase = serverEU 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.tok = nil
p.loginErr, p.loginRetryAt, p.loginFails = nil, time.Time{}, 0
p.client = &http.Client{Timeout: 30 * time.Second} p.client = &http.Client{Timeout: 30 * time.Second}
return nil return nil
} }
@@ -622,6 +633,57 @@ func chargerModeOptions(mode, statusDesc string) []string {
// ---- token management -------------------------------------------------------- // ---- 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. // ensureToken guarantees a non-expired auth token, logging in as needed.
// Serialized by p.mu so concurrent requests trigger at most one login. // Serialized by p.mu so concurrent requests trigger at most one login.
func (p *Plugin) ensureToken(ctx context.Context) (tokenInfo, error) { 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) { if p.tok != nil && time.Now().Before(p.tok.expiration) {
return *p.tok, nil return *p.tok, nil
} }
if err := p.login(ctx); err != nil { if err := p.attemptLogin(ctx); err != nil {
return tokenInfo{}, err return tokenInfo{}, err
} }
return *p.tok, nil 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 // login performs the ECDH + AES password exchange against passport/login and
// stores the returned token. Caller holds p.mu. // stores the returned token. Caller holds p.mu.
func (p *Plugin) login(ctx context.Context) error { 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) return fmt.Errorf("anker-solix: decode login response: %w", err)
} }
if env.Code != 0 { 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 == "" { if env.Data.AuthToken == "" || env.Data.UserID == "" {
return errors.New("anker-solix: login response missing auth_token or user_id") 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) { if status == http.StatusUnauthorized || status == http.StatusForbidden || isAuthCode(body) {
p.mu.Lock() p.mu.Lock()
p.tok = nil p.tok = nil
lerr := p.login(ctx) lerr := p.attemptLogin(ctx)
var newTok tokenInfo var newTok tokenInfo
if lerr == nil { if lerr == nil {
newTok = *p.tok 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 // 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. // and the others still answer; only losing all of them is an error.
func (p *Plugin) chargerInventory(ctx context.Context) (chargersDoc, 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() inv := newInventory()
var warnings []string var warnings []string
views, failed := 0, 0 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")
}
}