|
1 | 1 | package client |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "context" |
4 | 5 | "errors" |
| 6 | + "net/http" |
5 | 7 | "testing" |
| 8 | + "testing/synctest" |
6 | 9 | "time" |
7 | 10 | ) |
8 | 11 |
|
9 | | -// TestNextReconnectDelay_ExponentialGrowthAndCap 验证退避按 1s→2s→4s…指数增长并在 30s 封顶。 |
10 | | -// 客户端连续断线时若退一直被清零,就会退化成 1 秒高频重连, |
11 | | -// 服务端日志会被刷满,重连本身也会加剧网络拥塞。 |
12 | | -func TestNextReconnectDelay_ExponentialGrowthAndCap(t *testing.T) { |
| 12 | +// TestReconnectDelayAcrossFailures 验证短连接、稳定连接和随后的握手失败共享退避状态。 |
| 13 | +func TestReconnectDelayAcrossFailures(t *testing.T) { |
13 | 14 | client := &WSClient{reconnectDelay: minReconnectDelay} |
14 | | - |
15 | | - want := []time.Duration{ |
16 | | - 2 * time.Second, |
17 | | - 4 * time.Second, |
18 | | - 8 * time.Second, |
19 | | - 16 * time.Second, |
20 | | - maxReconnectDelay, |
21 | | - maxReconnectDelay, |
| 15 | + tests := []struct { |
| 16 | + name string // name 描述本次失败的连接状态。 |
| 17 | + uptime time.Duration // uptime 是刚结束连接的存活时长,握手失败为零。 |
| 18 | + want time.Duration // want 是本次失败后的等待时间。 |
| 19 | + }{ |
| 20 | + {"首次握手失败", 0, 2 * time.Second}, |
| 21 | + {"连接立刻断开", time.Second, 4 * time.Second}, |
| 22 | + {"随后握手失败", 0, 8 * time.Second}, |
| 23 | + {"未达到稳定门槛", stableConnectionThreshold - time.Second, 16 * time.Second}, |
| 24 | + {"达到退避上限", 0, maxReconnectDelay}, |
| 25 | + {"保持退避上限", 0, maxReconnectDelay}, |
| 26 | + {"稳定连接只复位一次", stableConnectionThreshold, 2 * time.Second}, |
| 27 | + {"稳定连接后第一次握手失败", 0, 4 * time.Second}, |
| 28 | + {"稳定连接后第二次握手失败", 0, 8 * time.Second}, |
22 | 29 | } |
23 | | - for i, expected := range want { |
24 | | - got := client.nextReconnectDelay() |
25 | | - if got != expected { |
26 | | - t.Fatalf("第 %d 次退避不符合预期: got %v want %v", i+1, got, expected) |
27 | | - } |
| 30 | + for _, test := range tests { |
| 31 | + t.Run(test.name, func(t *testing.T) { |
| 32 | + if got := client.nextReconnectDelay(test.uptime); got != test.want { |
| 33 | + t.Fatalf("重连等待不符: got %v want %v", got, test.want) |
| 34 | + } |
| 35 | + }) |
28 | 36 | } |
29 | 37 | } |
30 | 38 |
|
31 | | -// TestReconnectDelay_NotResetBySuccessfulHandshake 验证握手成功不会把退避清零。 |
32 | | -// |
33 | | -// 回归背景:旧实现每次 connect() 成功后执行 reconnectDelay = minReconnectDelay, |
34 | | -// 导致「连上即断」时退避永远回到 1 秒。生产日志里出现过 |
35 | | -// retryIn=1s 且 attempts=56、downtime=27m53s 的高频重连。 |
36 | | -func TestReconnectDelay_NotResetBySuccessfulHandshake(t *testing.T) { |
37 | | - client := &WSClient{reconnectDelay: minReconnectDelay} |
38 | | - |
39 | | - // 模拟连续三次「连上即断」。 |
40 | | - client.nextReconnectDelay() |
41 | | - client.nextReconnectDelay() |
42 | | - client.nextReconnectDelay() |
43 | | - |
44 | | - // 握手成功后 reconnectDelay 字段不应被重置回 1s。 |
45 | | - if client.reconnectDelay != 8*time.Second { |
46 | | - t.Fatalf("握手成功后退避不应被清零: got %v want %v", client.reconnectDelay, 8*time.Second) |
47 | | - } |
48 | | - |
49 | | - // 第四次退避应继续增长到 16s,而不是回到 2s。 |
50 | | - if got := client.nextReconnectDelay(); got != 16*time.Second { |
51 | | - t.Fatalf("连上即断后退避未继续增长: got %v want %v", got, 16*time.Second) |
52 | | - } |
| 39 | +// TestReconnectLoopDialFailuresBackOff 通过实际连接循环验证连续握手失败的等待时长和取消退出。 |
| 40 | +func TestReconnectLoopDialFailuresBackOff(t *testing.T) { |
| 41 | + synctest.Test(t, func(t *testing.T) { |
| 42 | + ctx, cancel := context.WithCancel(context.Background()) |
| 43 | + defer cancel() |
| 44 | + var attempts []time.Time |
| 45 | + client := &WSClient{ |
| 46 | + ctx: ctx, serverURL: "http://deploy.example.test", reconnectDelay: minReconnectDelay, |
| 47 | + wsHTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { |
| 48 | + attempts = append(attempts, time.Now()) |
| 49 | + if len(attempts) == 7 { |
| 50 | + cancel() |
| 51 | + } |
| 52 | + return nil, errors.New("模拟握手失败") |
| 53 | + })}, |
| 54 | + } |
| 55 | + client.startWebSocketLoop() |
| 56 | + if len(attempts) != 7 { |
| 57 | + t.Fatalf("实际握手次数不符: %d", len(attempts)) |
| 58 | + } |
| 59 | + for i, want := range []time.Duration{2, 4, 8, 16, 30, 30} { |
| 60 | + if got := attempts[i+1].Sub(attempts[i]); got != want*time.Second { |
| 61 | + t.Fatalf("第 %d 次实际重试等待 %v,期望 %v", i+1, got, want*time.Second) |
| 62 | + } |
| 63 | + } |
| 64 | + }) |
53 | 65 | } |
54 | 66 |
|
55 | | -// TestStableConnectionThreshold 验证稳定连接门槛常量符合预期, |
56 | | -// 且短连接与长连接的复位判定边界清晰。 |
57 | | -func TestStableConnectionThreshold(t *testing.T) { |
58 | | - if stableConnectionThreshold <= maxReconnectDelay { |
59 | | - t.Fatalf("稳定门槛应大于单次最大退避, 否则长退避永远无法覆盖真实故障: threshold=%v maxDelay=%v", |
60 | | - stableConnectionThreshold, maxReconnectDelay) |
61 | | - } |
62 | | - |
63 | | - // 短连接(抖动)不应触发复位。 |
64 | | - var shortConnection time.Duration = time.Second |
65 | | - if shortConnection >= stableConnectionThreshold { |
66 | | - t.Fatalf("秒级连接不应被判定为稳定连接: %v", shortConnection) |
67 | | - } |
68 | | - |
69 | | - // 长连接(真实故障)应触发复位。 |
70 | | - var longConnection time.Duration = 10 * time.Minute |
71 | | - if longConnection < stableConnectionThreshold { |
72 | | - t.Fatalf("分钟级连接应被判定为稳定连接: %v", longConnection) |
73 | | - } |
| 67 | +// TestWebSocketHandshakeTimeout 模拟代理一直不返回响应,验证握手会按期退出而非永久等待。 |
| 68 | +func TestWebSocketHandshakeTimeout(t *testing.T) { |
| 69 | + synctest.Test(t, func(t *testing.T) { |
| 70 | + client := &WSClient{ |
| 71 | + ctx: context.Background(), serverURL: "http://deploy.example.test", |
| 72 | + wsHTTPClient: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { |
| 73 | + <-request.Context().Done() |
| 74 | + return nil, request.Context().Err() |
| 75 | + })}, |
| 76 | + } |
| 77 | + started := time.Now() |
| 78 | + if err := client.connect(); !errors.Is(err, context.DeadlineExceeded) { |
| 79 | + t.Fatalf("握手未返回超时: %v", err) |
| 80 | + } |
| 81 | + if elapsed := time.Since(started); elapsed != wsHandshakeTimeout { |
| 82 | + t.Fatalf("握手等待时间不符: %v", elapsed) |
| 83 | + } |
| 84 | + }) |
74 | 85 | } |
75 | 86 |
|
76 | | -// TestLogConnectionFailure_LogsActualRetryIn 验证故障日志里的 retryIn 是本轮真实等待时长。 |
77 | | -// |
78 | | -// 回归背景:旧实现从 c.reconnectDelay 读取 retryIn,与实际 waitForContext |
79 | | -// 使用的时长可能不一致(日志显示 1s,实际等待更久),排查时会被误导。 |
80 | | -// 现在 retryIn 由调用方显式传入。 |
81 | | -func TestLogConnectionFailure_LogsActualRetryIn(t *testing.T) { |
82 | | - client := &WSClient{ |
83 | | - reconnectDelay: maxReconnectDelay, |
84 | | - ops: newOperationRunner(maxConcurrentOps), |
85 | | - } |
86 | | - // 传入一个与 c.reconnectDelay 不同的真实等待时长。 |
87 | | - actualRetryIn := 3 * time.Second |
88 | | - client.logConnectionFailure(errors.New("boom"), time.Now(), actualRetryIn) |
89 | | - |
90 | | - // 断言 reconnectDelay 字段未被日志调用改写,退避状态由调用方掌控。 |
91 | | - if client.reconnectDelay != maxReconnectDelay { |
92 | | - t.Fatalf("记录日志不应改动退避状态: got %v want %v", client.reconnectDelay, maxReconnectDelay) |
93 | | - } |
94 | | - |
95 | | - // reconnectSince 已被置位,后续同轮断线不再重复告警。 |
96 | | - if client.reconnectSince.IsZero() { |
97 | | - t.Fatal("首次断线应记录 reconnectSince") |
| 87 | +// TestLogConnectionFailureKeepsRetryState 验证记录故障不改动退避状态,同轮失败不重置故障起点。 |
| 88 | +func TestLogConnectionFailureKeepsRetryState(t *testing.T) { |
| 89 | + client := &WSClient{reconnectDelay: maxReconnectDelay} |
| 90 | + client.logConnectionFailure(errors.New("boom"), time.Now(), 3*time.Second) |
| 91 | + if client.reconnectDelay != maxReconnectDelay || client.reconnectSince.IsZero() { |
| 92 | + t.Fatal("记录故障改动了退避或遗漏故障起点") |
98 | 93 | } |
99 | 94 | before := client.reconnectSince |
100 | 95 | client.logConnectionFailure(errors.New("again"), time.Now(), time.Second) |
101 | 96 | if client.reconnectSince != before { |
102 | 97 | t.Fatal("同一轮断线不应重复刷新 reconnectSince") |
103 | 98 | } |
104 | 99 | } |
105 | | - |
106 | | -// TestReconnectDelay_BackoffAfterManyAttempts 记录长时间断线时的重试次数基线。 |
107 | | -// |
108 | | -// 该用例的价值在于固化一个事实:单纯指数退避并不能显著减少重试次数。 |
109 | | -// 30s 是退避上限,27 分钟断线仍会产生约 58 次尝试, |
110 | | -// 与生产日志观测到的 attempts=56 / downtime=27m53s 高度吻合。 |
111 | | -// 因此重连风暴的根因不是退避策略,而是连接被外部因素持续切断; |
112 | | -// 退避修复的价值仅在于让密集重试变得平滑,而非减少总量。 |
113 | | -func TestReconnectDelay_BackoffAfterManyAttempts(t *testing.T) { |
114 | | - client := &WSClient{reconnectDelay: minReconnectDelay} |
115 | | - |
116 | | - var elapsed time.Duration |
117 | | - attempts := 0 |
118 | | - for elapsed < 27*time.Minute { |
119 | | - elapsed += client.nextReconnectDelay() |
120 | | - attempts++ |
121 | | - if attempts > 200 { |
122 | | - t.Fatal("退避未生效,重试次数异常") |
123 | | - } |
124 | | - } |
125 | | - |
126 | | - // 27 分钟 / 30s 上限 ≈ 54 次,加上最初的1s→2s→4s 快速段,约 58 次。 |
127 | | - // 这个数字用于确认退避按预期工作(数量级正确),而非断言「重试很少」。 |
128 | | - if attempts < 50 || attempts > 65 { |
129 | | - t.Fatalf("27 分钟断线的重试次数偏离预期基线: %d 次 / %v", attempts, elapsed) |
130 | | - } |
131 | | -} |
132 | | - |
133 | | -// TestReconnectDelay_NotResetByStableConnection 验证稳定运行后的断线会复位退避。 |
134 | | -// 真实故障需要快速重连,不该被上一轮积攒的 30s 拖慢。 |
135 | | -func TestReconnectDelay_NotResetByStableConnection(t *testing.T) { |
136 | | - client := &WSClient{reconnectDelay: maxReconnectDelay} |
137 | | - |
138 | | - // 连接稳定运行超过门槛后,按startWebSocketLoop 的规则应复位退避。 |
139 | | - var uptime time.Duration = stableConnectionThreshold + time.Minute |
140 | | - if uptime < stableConnectionThreshold { |
141 | | - t.Fatal("测试前提错误:uptime 应超过稳定门槛") |
142 | | - } |
143 | | - |
144 | | - // 复位后第一次退避从最小档重新增长。 |
145 | | - client.reconnectDelay = minReconnectDelay |
146 | | - if got := client.nextReconnectDelay(); got != 2*time.Second { |
147 | | - t.Fatalf("稳定连接复位后应从最小档重新增长: got %v want %v", got, 2*time.Second) |
148 | | - } |
149 | | -} |
0 commit comments