diff --git a/internal/app/app.go b/internal/app/app.go index 2b441a5..861d175 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -608,7 +608,7 @@ func (a *App) connectDatabaseWithStartupRetry(rawConfig connection.ConnectionCon if err := dbInst.Connect(connectConfig); err == nil { if attempt > 1 { - logger.Warnf("数据库连接在启动保护重试后成功:%s 缓存Key=%s 尝试=%d/%d", formatConnSummary(effectiveConfig), cacheKey, attempt, startupConnectRetryAttempts) + logger.Warnf("数据库连接在重试后成功:%s 缓存Key=%s 尝试=%d/%d", formatConnSummary(effectiveConfig), cacheKey, attempt, startupConnectRetryAttempts) } return dbInst, effectiveConfig, nil } else { @@ -616,10 +616,10 @@ func (a *App) connectDatabaseWithStartupRetry(rawConfig connection.ConnectionCon wrapped := wrapConnectError(effectiveConfig, err) lastErr = wrapped logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), cacheKey) - if !a.shouldRetryStartupConnect(err, attempt) { + if !a.shouldRetryConnect(err, attempt) { return nil, effectiveConfig, wrapped } - logger.Warnf("检测到启动期瞬时网络失败,准备重试连接:%s 缓存Key=%s 尝试=%d/%d 延迟=%s 原因=%s", + logger.Warnf("检测到瞬时网络失败,准备重试连接:%s 缓存Key=%s 尝试=%d/%d 延迟=%s 原因=%s", formatConnSummary(effectiveConfig), cacheKey, attempt, startupConnectRetryAttempts, startupConnectRetryDelay, normalizeErrorMessage(err)) time.Sleep(startupConnectRetryDelay) } @@ -650,18 +650,21 @@ func (a *App) startupPhaseLabel() string { return fmt.Sprintf("稳定期(age=%s)", age) } -func (a *App) shouldRetryStartupConnect(err error, attempt int) bool { +func (a *App) shouldRetryConnect(err error, attempt int) bool { if attempt >= startupConnectRetryAttempts { return false } - if a == nil || a.startedAt.IsZero() { + if !isTransientStartupConnectError(err) { return false } - age := time.Since(a.startedAt) - if age < 0 || age > startupConnectRetryWindow { - return false + if a != nil && !a.startedAt.IsZero() { + age := time.Since(a.startedAt) + if age >= 0 && age <= startupConnectRetryWindow { + return true + } } - return isTransientStartupConnectError(err) + // Outside startup window, still grant one retry for transient network glitches. + return attempt == 1 } func isTransientStartupConnectError(err error) bool { diff --git a/internal/app/app_startup_connect_retry_test.go b/internal/app/app_startup_connect_retry_test.go index a840b30..b8fb027 100644 --- a/internal/app/app_startup_connect_retry_test.go +++ b/internal/app/app_startup_connect_retry_test.go @@ -2,11 +2,14 @@ package app import ( "errors" + "os" + "strings" "testing" "time" "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/db" + "GoNavi-Wails/internal/logger" ) type fakeStartupRetryDB struct { @@ -106,7 +109,7 @@ func TestConnectDatabaseWithStartupRetry_RetriesTransientFailureAndReappliesGlob } } -func TestConnectDatabaseWithStartupRetry_DoesNotRetryOutsideStartupWindow(t *testing.T) { +func TestConnectDatabaseWithStartupRetry_RetriesOnceOutsideStartupWindow(t *testing.T) { originalNewDatabaseFunc := newDatabaseFunc originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc defer func() { @@ -130,12 +133,165 @@ func TestConnectDatabaseWithStartupRetry_DoesNotRetryOutsideStartupWindow(t *tes a := &App{startedAt: time.Now().Add(-startupConnectRetryWindow - time.Second)} rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"} + _, _, err := a.connectDatabaseWithStartupRetry(rawConfig) + if err == nil { + t.Fatal("expected error, got nil") + } + if connectCalls != 2 { + t.Fatalf("expected 2 connect attempts outside startup window, got %d", connectCalls) + } +} + +func TestConnectDatabaseWithStartupRetry_DoesNotRetryOutsideStartupWindowForNonTransientError(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + defer func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + }() + + connectCalls := 0 + newDatabaseFunc = func(dbType string) (db.Database, error) { + return &fakeStartupRetryDB{ + connect: func(config connection.ConnectionConfig) error { + connectCalls++ + return errors.New("pq: password authentication failed") + }, + }, nil + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + a := &App{startedAt: time.Now().Add(-startupConnectRetryWindow - time.Second)} + rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"} + _, _, err := a.connectDatabaseWithStartupRetry(rawConfig) if err == nil { t.Fatal("expected error, got nil") } if connectCalls != 1 { - t.Fatalf("expected 1 connect attempt outside startup window, got %d", connectCalls) + t.Fatalf("expected 1 connect attempt outside startup window for non-transient error, got %d", connectCalls) + } +} + +func TestConnectDatabaseWithStartupRetry_LogsRetryHintOutsideStartupWindow(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + defer func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + }() + + logPath := logger.Path() + beforeSize := int64(0) + if fi, err := os.Stat(logPath); err == nil { + beforeSize = fi.Size() + } + + connectCalls := 0 + newDatabaseFunc = func(dbType string) (db.Database, error) { + return &fakeStartupRetryDB{ + connect: func(config connection.ConnectionConfig) error { + connectCalls++ + if connectCalls == 1 { + return errors.New("dial tcp 10.1.131.86:5432: connect: no route to host") + } + return nil + }, + }, nil + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + a := &App{startedAt: time.Now().Add(-startupConnectRetryWindow - time.Second)} + rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"} + + _, _, err := a.connectDatabaseWithStartupRetry(rawConfig) + if err != nil { + t.Fatalf("expected success after retry, got error: %v", err) + } + if connectCalls != 2 { + t.Fatalf("expected 2 connect attempts, got %d", connectCalls) + } + + logContent, readErr := os.ReadFile(logPath) + if readErr != nil { + t.Fatalf("read log failed: %v", readErr) + } + if int64(len(logContent)) < beforeSize { + t.Fatalf("expected log file to grow, before=%d after=%d", beforeSize, len(logContent)) + } + appended := string(logContent[beforeSize:]) + if !strings.Contains(appended, "检测到瞬时网络失败,准备重试连接") { + t.Fatalf("expected retry hint log in appended segment, got: %s", appended) + } +} + +func TestConnectDatabaseWithStartupRetry_OutsideStartupWindowTransientFailureStopsAfterOneRetry(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + defer func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + }() + + connectCalls := 0 + newDatabaseFunc = func(dbType string) (db.Database, error) { + return &fakeStartupRetryDB{ + connect: func(config connection.ConnectionConfig) error { + connectCalls++ + return errors.New("dial tcp 10.1.131.86:5432: connect: no route to host") + }, + }, nil + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + a := &App{startedAt: time.Now().Add(-startupConnectRetryWindow - time.Second)} + rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"} + + _, _, err := a.connectDatabaseWithStartupRetry(rawConfig) + if err == nil { + t.Fatal("expected error, got nil") + } + if connectCalls != 2 { + t.Fatalf("expected 2 connect attempts outside startup window for transient error, got %d", connectCalls) + } +} + +func TestConnectDatabaseWithStartupRetry_StartupWindowTransientFailureUsesFullRetryBudget(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + defer func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + }() + + connectCalls := 0 + newDatabaseFunc = func(dbType string) (db.Database, error) { + return &fakeStartupRetryDB{ + connect: func(config connection.ConnectionConfig) error { + connectCalls++ + return errors.New("dial tcp 10.1.131.86:5432: connect: no route to host") + }, + }, nil + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + a := &App{startedAt: time.Now()} + rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"} + + _, _, err := a.connectDatabaseWithStartupRetry(rawConfig) + if err == nil { + t.Fatal("expected error, got nil") + } + if connectCalls != startupConnectRetryAttempts { + t.Fatalf("expected %d connect attempts in startup window, got %d", startupConnectRetryAttempts, connectCalls) } }