feat(login, fetch): implement retry logic with backoff for login and node fetching
This commit is contained in:
1 parent
a7f5fac1d4
commit
064524e991
1 file changed
+97
-58
+97
-58
@@ -137,32 +137,55 @@ func NewClient(baseURL string) *Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// loginMaxAttempts bounds how many times Login tries before giving up.
|
// retryMaxAttempts bounds how many times a request is tried before giving up.
|
||||||
const loginMaxAttempts = 6
|
const retryMaxAttempts = 6
|
||||||
|
|
||||||
// retryableLoginError marks a login failure worth retrying: transport errors,
|
// retryBackoff is the linear backoff step between attempts: the n-th retry
|
||||||
// rate limiting and 5xx responses. A rejected credential or a malformed
|
// waits n*retryBackoff.
|
||||||
// response is not retryable, so Login fails fast instead of hammering Komari
|
const retryBackoff = 500 * time.Millisecond
|
||||||
// (which could trip account lockout or rate limits).
|
|
||||||
type retryableLoginError struct {
|
// 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
|
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 {
|
func retryableErrorf(format string, args ...any) error {
|
||||||
return &retryableLoginError{err: fmt.Errorf(format, args...)}
|
return &retryableError{err: fmt.Errorf(format, args...)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isRetryableLoginError reports whether err was produced by a failure that
|
// isRetryableError reports whether err was produced by a failure that another
|
||||||
// another attempt may resolve.
|
// attempt may resolve.
|
||||||
func isRetryableLoginError(err error) bool {
|
func isRetryableError(err error) bool {
|
||||||
var retryable *retryableLoginError
|
var retryable *retryableError
|
||||||
return errors.As(err, &retryable)
|
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
|
// truncateBody keeps error messages readable when a proxy or gateway answers
|
||||||
// with an unexpected (possibly HTML) payload.
|
// with an unexpected (possibly HTML) payload.
|
||||||
func truncateBody(body []byte) string {
|
func truncateBody(body []byte) string {
|
||||||
@@ -179,28 +202,19 @@ func truncateBody(body []byte) string {
|
|||||||
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
|
err := doWithRetry("komari login", func() error {
|
||||||
for attempt := 1; attempt <= loginMaxAttempts; attempt++ {
|
return c.loginOnce(username, password)
|
||||||
if err := c.loginOnce(username, password); err != nil {
|
})
|
||||||
lastErr = err
|
if err != nil {
|
||||||
if !isRetryableLoginError(err) || attempt == loginMaxAttempts {
|
// Drop any stale token so callers cannot keep using a dead session.
|
||||||
break
|
c.mu.Lock()
|
||||||
}
|
c.sessionToken = ""
|
||||||
postLog.Warning(fmt.Sprintf("Komari login attempt %d/%d failed, retrying: %v", attempt, loginMaxAttempts, err))
|
c.mu.Unlock()
|
||||||
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
|
return err
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
postLog.Info("Successfully logged into Komari Dashboard")
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop any stale token so callers cannot keep using a dead session.
|
postLog.Info("Successfully logged into Komari Dashboard")
|
||||||
c.mu.Lock()
|
return nil
|
||||||
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
|
// 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))
|
resp, err := c.httpClient.Post(c.baseURL+"/api/login", "application/json", bytes.NewReader(bodyJSON))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return retryableLoginErrorf("komari login request failed: %w", err)
|
return retryableErrorf("komari login request failed: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return retryableLoginErrorf("failed to read komari login response: %w", err)
|
return retryableErrorf("failed to read komari login response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case resp.StatusCode == http.StatusTooManyRequests, resp.StatusCode >= 500:
|
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:
|
case resp.StatusCode != http.StatusOK:
|
||||||
return fmt.Errorf("komari login request failed: unexpected status code %d: %s", resp.StatusCode, truncateBody(respBody))
|
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.
|
// 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) {
|
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{
|
payload, err := json.Marshal(map[string]any{
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"method": "common:getNodes",
|
"method": "common:getNodes",
|
||||||
@@ -286,16 +333,24 @@ func (c *Client) FetchNodes() ([]NodeInfo, error) {
|
|||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
return nil, fmt.Errorf("komari nodes request failed: unexpected status code %d", resp.StatusCode)
|
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
|
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)
|
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 {
|
for _, n := range nodeMap {
|
||||||
nodes = append(nodes, n)
|
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
|
return nodes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user