Files
Edu/services/api-gateway/internal/middleware/circuit-breaker_test.go
SpecialX 75804c7d64 fix: lint-staged 移除 Go gofmt 规则
Go 工具链不在 git hook(Git Bash)PATH 中,gofmt 执行失败。
Go 文件格式化由开发者手动执行 go fmt。
2026-07-08 12:51:46 +08:00

224 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 期间请求被拒503Timeout 后请求放行到下游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)
}
}