From 5844cd7c01ce1c6780df45ad627b890f93b5e863 Mon Sep 17 00:00:00 2001 From: DurianPankek Date: Fri, 27 Mar 2026 16:27:46 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(app):=20=E4=B8=BA=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E6=9C=9F=E9=A6=96=E6=AC=A1=E8=BF=9E=E6=8E=A5=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E7=9E=AC=E6=97=B6=E7=BD=91=E7=BB=9C=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/app.go | 21 ++- .../app/app_startup_connect_retry_test.go | 160 +++++++++++++++++- 2 files changed, 170 insertions(+), 11 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 8f6ae70..2ec3bd4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -603,7 +603,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 { @@ -611,10 +611,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) } @@ -645,18 +645,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) } }