feat(agent): 注册失败退避重试不停机,404 时日志提示检查 master_url 基础路径

This commit is contained in:
Muse
2026-10-21 22:05:09 +00:00
parent 051a74be80
commit 7d03749246
2 changed files with 214 additions and 4 deletions
+135
View File
@@ -0,0 +1,135 @@
package agent
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// stubRegisterWait replaces the retry sleep with a recorder that never
// actually sleeps. allowWaits is how many waits return true (keep
// retrying) before returning false (agent stopping).
func stubRegisterWait(t *testing.T, allowWaits int) *[]time.Duration {
t.Helper()
delays := &[]time.Duration{}
orig := registerWait
registerWait = func(_ chan struct{}, d time.Duration) bool {
*delays = append(*delays, d)
return len(*delays) <= allowWaits
}
t.Cleanup(func() { registerWait = orig })
return delays
}
func captureLogs(t *testing.T) *[]string {
t.Helper()
var mu sync.Mutex
lines := &[]string{}
prev := logger.SetLogHook(func(_, msg string) {
mu.Lock()
*lines = append(*lines, msg)
mu.Unlock()
})
t.Cleanup(func() { logger.SetLogHook(prev) })
return lines
}
// scriptedMaster answers register requests from a status-code script;
// once the script is exhausted it answers 200.
func scriptedMaster(t *testing.T, script []int, hits *int) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/panel/api/agent/register") {
w.WriteHeader(http.StatusNotFound)
return
}
*hits++
code := http.StatusOK
if *hits <= len(script) {
code = script[*hits-1]
}
w.WriteHeader(code)
fmt.Fprint(w, `{}`)
}))
t.Cleanup(srv.Close)
return srv
}
func TestRegistrationBackoffSequence(t *testing.T) {
want := []time.Duration{5, 15, 30, 60, 60, 60}
for i, w := range want {
if got := registrationBackoff(i + 1); got != w*time.Second {
t.Errorf("registrationBackoff(%d) = %s, want %ss", i+1, got, w)
}
}
}
func TestRegisterWithRetrySucceedsAfter404s(t *testing.T) {
hits := 0
srv := scriptedMaster(t, []int{404, 404}, &hits)
delays := stubRegisterWait(t, 10)
logs := captureLogs(t)
ag := New(&Config{MasterURL: srv.URL, Token: "tok", NodeName: "n1"})
if err := ag.registerWithRetry(); err != nil {
t.Fatalf("registerWithRetry: %v", err)
}
if hits != 3 {
t.Fatalf("register hits = %d, want 3 (404, 404, 200)", hits)
}
if len(*delays) != 2 || (*delays)[0] != 5*time.Second || (*delays)[1] != 15*time.Second {
t.Fatalf("retry delays = %v, want [5s 15s]", *delays)
}
joined := strings.Join(*logs, "\n")
if !strings.Contains(joined, "检查 master_url 是否包含面板基础路径") {
t.Errorf("404 hint missing from logs:\n%s", joined)
}
if !strings.Contains(joined, "retrying in 5s") {
t.Errorf("retry log line missing:\n%s", joined)
}
}
func TestRegisterWithRetryAbortsOnStop(t *testing.T) {
hits := 0
srv := scriptedMaster(t, []int{500, 500, 500, 500, 500, 500, 500, 500}, &hits)
// First wait says "stopping": the loop must give up immediately
// instead of burning through the script.
delays := stubRegisterWait(t, 0)
ag := New(&Config{MasterURL: srv.URL, Token: "tok", NodeName: "n1"})
err := ag.registerWithRetry()
if !errors.Is(err, errRegistrationAborted) {
t.Fatalf("registerWithRetry err = %v, want errRegistrationAborted", err)
}
if hits != 1 {
t.Fatalf("register hits = %d, want 1 (no retry after stop)", hits)
}
if len(*delays) != 1 {
t.Fatalf("waits = %d, want 1", len(*delays))
}
}
func TestRegisterStatusErrorCarriesCode(t *testing.T) {
hits := 0
srv := scriptedMaster(t, []int{404}, &hits)
ag := New(&Config{MasterURL: srv.URL, Token: "tok", NodeName: "n1"})
err := ag.Register()
var st *statusError
if !errors.As(err, &st) {
t.Fatalf("Register err = %v (%T), want *statusError", err, err)
}
if st.code != http.StatusNotFound {
t.Fatalf("statusError.code = %d, want 404", st.code)
}
// Error text must stay the historical one — scripts may grep for it.
if !strings.Contains(err.Error(), "register failed with status 404") {
t.Fatalf("error text changed: %q", err.Error())
}
}