feat(login): enhance login functionality with retry logic and error handling
Build / ubuntu-latest (push) Failing after 0s
Build / windows-latest (push) Canceled after 2m55s

This commit is contained in:
NanamiAdmin committed 2026-11-01 20:18:17 +08:00
1 parent a1a69d95f5
commit a7f5fac1d4
3 files changed
+125 -42

No files matched your search

+4
View File
@@ -1,3 +1,7 @@
## Ver.0.2.3.8
### Features
- [Add retry attemption when login to Komari Dashboard failed]
## Ver.0.2.2.7-8c2159f.pre-release ## Ver.0.2.2.7-8c2159f.pre-release
### Features ### Features
- [Add flap inhibiton for nodes]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/03e74f49c36ba05995651135bd0f472b95bfede6") - [Add flap inhibiton for nodes]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/03e74f49c36ba05995651135bd0f472b95bfede6")
+8 -8
View File
@@ -14,9 +14,9 @@ type SoftwareInfoStr struct {
var SoftwareInfo = SoftwareInfoStr{ var SoftwareInfo = SoftwareInfoStr{
Name: "Nukumizu", Name: "Nukumizu",
Version: "0.2.2", Version: "0.2.3",
Developer: "Madobi Nanami", Developer: "Madobi Nanami",
BuildVer: 7, BuildVer: 8,
CommitHash: "unknown", CommitHash: "unknown",
Description: "Remote server monitoring and command execution subsystem for Komari", Description: "Remote server monitoring and command execution subsystem for Komari",
BuildType: "pre-release", BuildType: "pre-release",
@@ -24,13 +24,13 @@ var SoftwareInfo = SoftwareInfoStr{
} }
type ConfigPathStr struct { type ConfigPathStr struct {
Global string Global string
BotUserConfig string BotUserConfig string
BotNodeConfig string BotNodeConfig string
} }
var ConfigPath = ConfigPathStr{ var ConfigPath = ConfigPathStr{
Global: "config.json", Global: "config.json",
BotUserConfig: "bot_user_config.json", BotUserConfig: "bot_user_config.json",
BotNodeConfig: "bot_node_config.json", BotNodeConfig: "bot_node_config.json",
} }
+113 -34
View File
@@ -3,6 +3,7 @@ package komari
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -44,8 +45,8 @@ type NodeInfo struct {
SwapTotal int64 `json:"swap_total"` SwapTotal int64 `json:"swap_total"`
DiskTotal int64 `json:"disk_total"` DiskTotal int64 `json:"disk_total"`
Weight float64 `json:"weight"` Weight float64 `json:"weight"`
IPv4 string `json:"ipv4"` IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"` IPv6 string `json:"ipv6"`
Price float64 `json:"price"` Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"` BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal"` AutoRenewal bool `json:"auto_renewal"`
@@ -73,32 +74,32 @@ type TaskResult struct {
// ClientInfo holds client information returned with task results. // ClientInfo holds client information returned with task results.
type ClientInfo struct { type ClientInfo struct {
Name string `json:"name"` Name string `json:"name"`
CPUName string `json:"cpu_name"` CPUName string `json:"cpu_name"`
Virtualization string `json:"virtualization"` Virtualization string `json:"virtualization"`
Arch string `json:"arch"` Arch string `json:"arch"`
CPUCores int `json:"cpu_cores"` CPUCores int `json:"cpu_cores"`
CPUPhysicalCores int `json:"cpu_physical_cores"` CPUPhysicalCores int `json:"cpu_physical_cores"`
OS string `json:"os"` OS string `json:"os"`
KernelVersion string `json:"kernel_version"` KernelVersion string `json:"kernel_version"`
GPUName string `json:"gpu_name"` GPUName string `json:"gpu_name"`
Region string `json:"region"` Region string `json:"region"`
MemTotal int64 `json:"mem_total"` MemTotal int64 `json:"mem_total"`
SwapTotal int64 `json:"swap_total"` SwapTotal int64 `json:"swap_total"`
DiskTotal int64 `json:"disk_total"` DiskTotal int64 `json:"disk_total"`
Weight int `json:"weight"` Weight int `json:"weight"`
Price int `json:"price"` Price int `json:"price"`
BillingCycle int `json:"billing_cycle"` BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal"` AutoRenewal bool `json:"auto_renewal"`
Currency string `json:"currency"` Currency string `json:"currency"`
ExpiredAt *string `json:"expired_at"` ExpiredAt *string `json:"expired_at"`
Group string `json:"group"` Group string `json:"group"`
Tags string `json:"tags"` Tags string `json:"tags"`
Hidden bool `json:"hidden"` Hidden bool `json:"hidden"`
TrafficLimit int64 `json:"traffic_limit"` TrafficLimit int64 `json:"traffic_limit"`
TrafficLimitType string `json:"traffic_limit_type"` TrafficLimitType string `json:"traffic_limit_type"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
// KomariResponse is the standard response envelope from Komari API. // KomariResponse is the standard response envelope from Komari API.
@@ -136,27 +137,106 @@ func NewClient(baseURL string) *Client {
} }
} }
// Login authenticates to Komari and stores the session token cookie. // loginMaxAttempts bounds how many times Login tries before giving up.
const loginMaxAttempts = 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 {
err error
}
func (e *retryableLoginError) Error() string { return e.err.Error() }
func (e *retryableLoginError) Unwrap() error { return e.err }
func retryableLoginErrorf(format string, args ...any) error {
return &retryableLoginError{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
return errors.As(err, &retryable)
}
// truncateBody keeps error messages readable when a proxy or gateway answers
// with an unexpected (possibly HTML) payload.
func truncateBody(body []byte) string {
const limit = 512
if len(body) > limit {
return string(body[:limit]) + "..."
}
return strings.TrimSpace(string(body))
}
// Login authenticates to Komari and stores the session token cookie. Transport
// errors, 429 and 5xx responses are retried with linear backoff; credential or
// protocol failures return immediately.
func (c *Client) Login(username, password string) error { func (c *Client) Login(username, password string) error {
postLog.Info("Logging into Komari Dashboard at " + c.baseURL) 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
}
// Drop any stale token so callers cannot keep using a dead session.
c.mu.Lock()
c.sessionToken = ""
c.mu.Unlock()
return fmt.Errorf("komari login failed after %d attempts: %w", loginMaxAttempts, lastErr)
}
// loginOnce performs a single login round trip. The response body is closed
// before returning so retries never accumulate open connections.
func (c *Client) loginOnce(username, password string) error {
body := map[string]string{ body := map[string]string{
"username": username, "username": username,
"password": password, "password": password,
} }
bodyJSON, _ := json.Marshal(body) bodyJSON, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal komari login request: %w", err)
}
resp, err := c.httpClient.Post(c.baseURL+"/api/login", "application/json", bytes.NewReader(bodyJSON)) resp, err := c.httpClient.Post(c.baseURL+"/api/login", "application/json", bytes.NewReader(bodyJSON))
if err != nil { if err != nil {
return fmt.Errorf("komari login request failed: %w", err) return retryableLoginErrorf("komari login request failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return retryableLoginErrorf("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))
case resp.StatusCode != http.StatusOK:
return fmt.Errorf("komari login request failed: unexpected status code %d: %s", resp.StatusCode, truncateBody(respBody))
}
var kr KomariResponse var kr KomariResponse
if err := json.NewDecoder(resp.Body).Decode(&kr); err != nil { if err := json.Unmarshal(respBody, &kr); err != nil {
if config.IsDebugMode() { if config.IsDebugMode() {
respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("failed to parse komari login response: %w.\nResponse: %s", err, truncateBody(respBody))
return fmt.Errorf("failed to parse komari login response: %w.\nResponse: %s", err, respBody)
} }
return fmt.Errorf("failed to parse komari login response: %w", err) return fmt.Errorf("failed to parse komari login response: %w", err)
} }
@@ -171,7 +251,6 @@ func (c *Client) Login(username, password string) error {
c.mu.Lock() c.mu.Lock()
c.sessionToken = cookie.Value c.sessionToken = cookie.Value
c.mu.Unlock() c.mu.Unlock()
postLog.Info("Successfully logged into Komari Dashboard")
return nil return nil
} }
} }