package middleware import ( "errors" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" "github.com/gin-gonic/gin" "github.com/sony/gobreaker/v2" ) // newTestCircuitBreaker 构造与生产 CircuitBreaker 中间件相同语义、但可配置时序参数的版本, // 用于在测试中加速 CLOSED → OPEN → HALF_OPEN → CLOSED 状态机验证。 func newTestCircuitBreaker(t *testing.T, interval, timeout time.Duration) (gin.HandlerFunc, *gobreaker.CircuitBreaker[struct{}]) { t.Helper() cb := gobreaker.NewCircuitBreaker[struct{}](gobreaker.Settings{ Name: "test-svc", MaxRequests: 1, Interval: interval, Timeout: timeout, ReadyToTrip: func(counts gobreaker.Counts) bool { if counts.Requests == 0 { return false } return counts.TotalFailures*2 > counts.Requests }, }) return func(c *gin.Context) { _, err := cb.Execute(func() (struct{}, error) { c.Next() if c.Writer.Status() >= 500 { return struct{}{}, errors.New("downstream_error") } return struct{}{}, nil }) if err != nil { if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) { c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{ "error": "circuit_open", "retry_after": 30, }) return } return } }, cb } func setupRouter(cbMW gin.HandlerFunc, downstreamStatus int) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() r.Use(cbMW) r.GET("/test", func(c *gin.Context) { c.Status(downstreamStatus) }) return r } // TestCircuitBreaker_ClosedToOpen 验证 5 秒窗口内错误率 > 50% 触发 CLOSED → OPEN。 // ReadyToTrip 逻辑:TotalFailures*2 > Requests。 // 第 1 次 5xx 后(Requests=1, Failures=1, 1*2>1)即触发;第 2 次请求被熔断直接返回 503。 func TestCircuitBreaker_ClosedToOpen(t *testing.T) { mw, cb := newTestCircuitBreaker(t, time.Second, 30*time.Second) r := setupRouter(mw, http.StatusInternalServerError) // 第 1 次 5xx:放行到下游,触发 ReadyToTrip w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/test", nil) r.ServeHTTP(w, req) if w.Code != http.StatusInternalServerError { t.Fatalf("第 1 次请求期望 500,实际 %d", w.Code) } // 第 2 次请求应触发熔断:返回 503 + circuit_open w = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/test", nil) r.ServeHTTP(w, req) if w.Code != http.StatusServiceUnavailable { t.Fatalf("熔断后应返回 503,实际 %d", w.Code) } if !strings.Contains(w.Body.String(), "circuit_open") { t.Fatalf("响应体应包含 circuit_open,实际 %s", w.Body.String()) } if cb.State() != gobreaker.StateOpen { t.Fatalf("CB 状态应为 OPEN,实际 %s", cb.State()) } } // TestCircuitBreaker_OpenToHalfOpen 验证 OPEN 状态经过 Timeout 后转 HALF_OPEN,允许 1 个探测请求。 // 注意:HALF_OPEN 状态只在探测请求执行期间可见;探测完成后(无论成功失败)状态会立刻转 CLOSED 或回 OPEN。 // 因此本测试通过行为验证:OPEN 期间请求被拒(503),Timeout 后请求放行到下游(500)证明进入了 HALF_OPEN。 func TestCircuitBreaker_OpenToHalfOpen(t *testing.T) { mw, cb := newTestCircuitBreaker(t, time.Second, 100*time.Millisecond) r := setupRouter(mw, http.StatusInternalServerError) // 触发熔断(1 次 5xx 即可) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/test", nil) r.ServeHTTP(w, req) if cb.State() != gobreaker.StateOpen { t.Fatalf("预置条件失败:CB 应为 OPEN,实际 %s", cb.State()) } // 立即再次请求:应被熔断拒绝(503) w = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/test", nil) r.ServeHTTP(w, req) if w.Code != http.StatusServiceUnavailable { t.Fatalf("OPEN 期间请求应被拒(503),实际 %d", w.Code) } // 等待 Timeout + 少量缓冲 → 状态转为 HALF_OPEN time.Sleep(150 * time.Millisecond) // 探测请求应放行到下游(返回 500,而非 503) // 证明状态已从 OPEN → HALF_OPEN(否则会返回 503) w = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/test", nil) r.ServeHTTP(w, req) if w.Code != http.StatusInternalServerError { t.Fatalf("HALF_OPEN 探测请求应放行到下游(500),实际 %d", w.Code) } // 探测失败(下游 500)→ 状态回 OPEN if cb.State() != gobreaker.StateOpen { t.Fatalf("探测失败后 CB 应回 OPEN,实际 %s", cb.State()) } } // TestCircuitBreaker_HalfOpenToClosed 验证 HALF_OPEN 探测成功后转 CLOSED。 func TestCircuitBreaker_HalfOpenToClosed(t *testing.T) { mw, cb := newTestCircuitBreaker(t, time.Second, 100*time.Millisecond) // 下游成功(200),让探测能成功 r := setupRouter(mw, http.StatusOK) // 先用另一个 router 触发熔断(下游 500) badR := setupRouter(mw, http.StatusInternalServerError) for i := 0; i < 4; i++ { w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/test", nil) badR.ServeHTTP(w, req) } if cb.State() != gobreaker.StateOpen { t.Fatalf("预置条件失败:CB 应为 OPEN,实际 %s", cb.State()) } // 等待 Timeout → HALF_OPEN time.Sleep(150 * time.Millisecond) // 探测请求(下游 200)→ 成功 → 转 CLOSED w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/test", nil) r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("探测请求应返回 200,实际 %d", w.Code) } if cb.State() != gobreaker.StateClosed { t.Fatalf("探测成功后 CB 应为 CLOSED,实际 %s", cb.State()) } } // TestCircuitBreaker_HalfOpenToOpen 验证 HALF_OPEN 探测失败后转回 OPEN。 func TestCircuitBreaker_HalfOpenToOpen(t *testing.T) { mw, cb := newTestCircuitBreaker(t, time.Second, 100*time.Millisecond) r := setupRouter(mw, http.StatusInternalServerError) // 触发熔断 for i := 0; i < 4; i++ { w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/test", nil) r.ServeHTTP(w, req) } if cb.State() != gobreaker.StateOpen { t.Fatalf("预置条件失败:CB 应为 OPEN,实际 %s", cb.State()) } // 等待 Timeout → HALF_OPEN time.Sleep(150 * time.Millisecond) // 探测请求(下游 500)→ 失败 → 转 OPEN w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/test", nil) r.ServeHTTP(w, req) if cb.State() != gobreaker.StateOpen { t.Fatalf("探测失败后 CB 应重新转 OPEN,实际 %s", cb.State()) } } // TestCircuitBreaker_4xxNotCounted 验证 4xx 响应不计入熔断失败计数。 func TestCircuitBreaker_4xxNotCounted(t *testing.T) { var callCount int32 mw, cb := newTestCircuitBreaker(t, time.Second, 30*time.Second) gin.SetMode(gin.TestMode) r := gin.New() r.Use(mw) r.GET("/test", func(c *gin.Context) { atomic.AddInt32(&callCount, 1) c.Status(http.StatusNotFound) // 404 }) // 触发 10 次 404,应不触发熔断 for i := 0; i < 10; i++ { w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/test", nil) r.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Fatalf("请求 %d 应返回 404,实际 %d", i+1, w.Code) } } if cb.State() != gobreaker.StateClosed { t.Fatalf("4xx 不应触发熔断,CB 应保持 CLOSED,实际 %s", cb.State()) } // 所有请求应都到达下游(未被熔断短路) if got := atomic.LoadInt32(&callCount); got != 10 { t.Fatalf("下游应被调用 10 次,实际 %d", got) } }