feat(login, fetch): implement retry logic with backoff for login and node fetching
Build / ubuntu-latest (push) Failing after 0s
Build / windows-latest (push) Canceled after 6m45s

This commit is contained in:
NanamiAdmin committed 2026-11-01 20:21:18 +08:00
1 parent a7f5fac1d4
commit 064524e991
1 file changed
+94 -55
+94 -55
View File
@@ -137,32 +137,55 @@ func NewClient(baseURL string) *Client {
}
}
// loginMaxAttempts bounds how many times Login tries before giving up.
const loginMaxAttempts = 6
// retryMaxAttempts bounds how many times a request is tried before giving up.
const retryMaxAttempts = 6
// retryableLoginError marks a login failure worth retrying: transport errors,
// rate limiting and 5xx responses. A rejected credential or a malformed
// response is not retryable, so Login fails fast instead of hammering Komari
// (which could trip account lockout or rate limits).
type retryableLoginError struct {
// retryBackoff is the linear backoff step between attempts: the n-th retry
// waits n*retryBackoff.
const retryBackoff = 500 * time.Millisecond
// retryableError marks a failure worth retrying: transport errors, rate
// limiting and 5xx responses. Rejected credentials, 4xx responses and
// malformed payloads are not retryable, so callers fail fast instead of
// hammering Komari (which could trip account lockout or rate limits).
type retryableError struct {
err error
}
func (e *retryableLoginError) Error() string { return e.err.Error() }
func (e *retryableError) Error() string { return e.err.Error() }
func (e *retryableLoginError) Unwrap() error { return e.err }
func (e *retryableError) Unwrap() error { return e.err }
func retryableLoginErrorf(format string, args ...any) error {
return &retryableLoginError{err: fmt.Errorf(format, args...)}
func retryableErrorf(format string, args ...any) error {
return &retryableError{err: fmt.Errorf(format, args...)}
}
// isRetryableLoginError reports whether err was produced by a failure that
// another attempt may resolve.
func isRetryableLoginError(err error) bool {
var retryable *retryableLoginError
// isRetryableError reports whether err was produced by a failure that another
// attempt may resolve.
func isRetryableError(err error) bool {
var retryable *retryableError
return errors.As(err, &retryable)
}
// doWithRetry runs fn until it succeeds, fails with a non-retryable error, or
// exhausts retryMaxAttempts attempts. what names the operation in logs and in
// the returned error.
func doWithRetry(what string, fn func() error) error {
var lastErr error
for attempt := 1; ; attempt++ {
if err := fn(); err != nil {
lastErr = err
if !isRetryableError(err) || attempt == retryMaxAttempts {
return fmt.Errorf("%s failed after %d attempt(s): %w", what, attempt, lastErr)
}
postLog.Warning(fmt.Sprintf("%s attempt %d/%d failed, retrying: %v", what, attempt, retryMaxAttempts, err))
time.Sleep(time.Duration(attempt) * retryBackoff)
continue
}
return nil
}
}
// truncateBody keeps error messages readable when a proxy or gateway answers
// with an unexpected (possibly HTML) payload.
func truncateBody(body []byte) string {
@@ -179,28 +202,19 @@ func truncateBody(body []byte) string {
func (c *Client) Login(username, password string) error {
postLog.Info("Logging into Komari Dashboard at " + c.baseURL)
var lastErr error
for attempt := 1; attempt <= loginMaxAttempts; attempt++ {
if err := c.loginOnce(username, password); err != nil {
lastErr = err
if !isRetryableLoginError(err) || attempt == loginMaxAttempts {
break
}
postLog.Warning(fmt.Sprintf("Komari login attempt %d/%d failed, retrying: %v", attempt, loginMaxAttempts, err))
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
continue
}
postLog.Info("Successfully logged into Komari Dashboard")
return nil
}
err := doWithRetry("komari login", func() error {
return c.loginOnce(username, password)
})
if err != nil {
// Drop any stale token so callers cannot keep using a dead session.
c.mu.Lock()
c.sessionToken = ""
c.mu.Unlock()
return err
}
return fmt.Errorf("komari login failed after %d attempts: %w", loginMaxAttempts, lastErr)
postLog.Info("Successfully logged into Komari Dashboard")
return nil
}
// loginOnce performs a single login round trip. The response body is closed
@@ -217,18 +231,18 @@ func (c *Client) loginOnce(username, password string) error {
resp, err := c.httpClient.Post(c.baseURL+"/api/login", "application/json", bytes.NewReader(bodyJSON))
if err != nil {
return retryableLoginErrorf("komari login request failed: %w", err)
return retryableErrorf("komari login request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return retryableLoginErrorf("failed to read komari login response: %w", err)
return retryableErrorf("failed to read komari login response: %w", err)
}
switch {
case resp.StatusCode == http.StatusTooManyRequests, resp.StatusCode >= 500:
return retryableLoginErrorf("komari login request failed: unexpected status code %d: %s", resp.StatusCode, truncateBody(respBody))
return retryableErrorf("komari login request failed: unexpected status code %d: %s", resp.StatusCode, truncateBody(respBody))
case resp.StatusCode != http.StatusOK:
return fmt.Errorf("komari login request failed: unexpected status code %d: %s", resp.StatusCode, truncateBody(respBody))
}
@@ -267,7 +281,40 @@ func (c *Client) GetSessionToken() string {
}
// FetchNodes retrieves all nodes from Komari via the JSON-RPC2 endpoint.
// Transport errors, 429 and 5xx responses are retried with linear backoff;
// RPC-level errors and malformed payloads fail immediately.
func (c *Client) FetchNodes() ([]NodeInfo, error) {
var nodes []NodeInfo
err := doWithRetry("komari nodes fetch", func() error {
var err error
nodes, err = c.fetchNodesOnce()
return err
})
if err != nil {
return nil, err
}
// Persist the fetched node UUIDs to bot_node_config.json on every fetch
// (startup login, WebSocket reconnect and periodic refresh all funnel
// through FetchNodes) so the file always reflects the nodes Komari
// currently manages. A persistence failure is only logged: the node data
// itself was fetched successfully and must not be discarded over a disk
// write problem.
uuids := make([]string, len(nodes))
for i, n := range nodes {
uuids[i] = n.UUID
}
if err := config.SaveBotNodeConfig(global.ConfigPath.BotNodeConfig, uuids); err != nil {
postLog.Warning("Failed to save bot node config: " + err.Error())
}
// postLog.Debug(fmt.Sprintf("Fetched %d nodes from Komari", len(nodes)))
return nodes, nil
}
// fetchNodesOnce performs a single nodes RPC round trip. The response body is
// closed before returning so retries never accumulate open connections.
func (c *Client) fetchNodesOnce() ([]NodeInfo, error) {
payload, err := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"method": "common:getNodes",
@@ -286,16 +333,24 @@ func (c *Client) FetchNodes() ([]NodeInfo, error) {
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("komari nodes request failed: %w", err)
return nil, retryableErrorf("komari nodes request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("komari nodes request failed: unexpected status code %d", resp.StatusCode)
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, retryableErrorf("failed to read komari nodes response: %w", err)
}
switch {
case resp.StatusCode == http.StatusTooManyRequests, resp.StatusCode >= 500:
return nil, retryableErrorf("komari nodes request failed: unexpected status code %d: %s", resp.StatusCode, truncateBody(respBody))
case resp.StatusCode != http.StatusOK:
return nil, fmt.Errorf("komari nodes request failed: unexpected status code %d: %s", resp.StatusCode, truncateBody(respBody))
}
var rpcResp jsonRpcResponse
if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
return nil, fmt.Errorf("failed to parse komari nodes RPC response: %w", err)
}
@@ -312,22 +367,6 @@ func (c *Client) FetchNodes() ([]NodeInfo, error) {
for _, n := range nodeMap {
nodes = append(nodes, n)
}
// Persist the fetched node UUIDs to bot_node_config.json on every fetch
// (startup login, WebSocket reconnect and periodic refresh all funnel
// through FetchNodes) so the file always reflects the nodes Komari
// currently manages. A persistence failure is only logged: the node data
// itself was fetched successfully and must not be discarded over a disk
// write problem.
uuids := make([]string, len(nodes))
for i, n := range nodes {
uuids[i] = n.UUID
}
if err := config.SaveBotNodeConfig(global.ConfigPath.BotNodeConfig, uuids); err != nil {
postLog.Warning("Failed to save bot node config: " + err.Error())
}
// postLog.Debug(fmt.Sprintf("Fetched %d nodes from Komari", len(nodes)))
return nodes, nil
}