Files
Edu/services/api-gateway/internal/middleware/circuit-breaker.go
SpecialX 4307f6b73c feat(api-gateway): 实现 W1-W8 网关硬化与 P2-P5 路由扩展
依据 coord-final-decisions §3.8 W1-W8 裁决与
president-final-rulings §2.15/§2.16/§2.19 完整实现网关硬化:

- W1/W2: 错误码 GW_ 前缀 + ActionState 信封响应体
- W3: 全量替换为 log/slog 结构化日志
- W4: /readyz 并行 ping 9 下游 + 软失败规则
- W5: 7 个业务 Prometheus 指标 + /metrics 端点
- W6: tracer 资源属性补全(name/version/env/host)
- W7: DevMode=true && ENV=production panic 防护
- W8: 保持共享 downstream 熔断

P2 RS256 升级:接入 shared-go/jwks.Fetcher(TTL 5min)。
P2.7+P3-P5 路由扩展:student/parent/messages/dashboard。
文档同步:README/01/02/known-issues,arch.db 已更新。
质量校验:go vet + build + test 均通过。
2026-07-10 18:15:48 +08:00

80 lines
2.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"
"log/slog"
"net/http"
"time"
"github.com/edu-cloud/api-gateway/internal/observability"
"github.com/gin-gonic/gin"
"github.com/sony/gobreaker/v2"
)
// CircuitBreaker 返回针对指定服务的熔断中间件W8 裁决:保持共享 downstream
//
// 基于 gobreaker v2 实现:
// - Interval=5sCLOSED 状态下的统计窗口
// - ReadyToTrip错误率 > 50% 触发 OPEN
// - Timeout=30sOPEN 持续 30 秒后转 HALF_OPEN
// - MaxRequests=1HALF_OPEN 仅允许 1 个探测请求
//
// 仅当下游返回 5xx 视为失败4xx 与 2xx 不计入熔断。
// 熔断打开或半开探测名额已满时返回 503 + ActionState 信封W1/W2 裁决):
//
// {"success":false,"error":{"code":"GW_CIRCUIT_OPEN","message":"..."},"retry_after":30}
func CircuitBreaker(serviceName string) gin.HandlerFunc {
cb := gobreaker.NewCircuitBreaker[struct{}](gobreaker.Settings{
Name: serviceName,
MaxRequests: 1,
Interval: 5 * time.Second,
Timeout: 30 * time.Second,
ReadyToTrip: func(counts gobreaker.Counts) bool {
// 请求数为 0 时不触发,避免除零
if counts.Requests == 0 {
return false
}
// 错误率 > 50%
return counts.TotalFailures*2 > counts.Requests
},
OnStateChange: func(name string, from, to gobreaker.State) {
observability.SetCircuitBreakerState(name, from.String(), 0)
observability.SetCircuitBreakerState(name, to.String(), 1)
slog.Info("circuit breaker state changed",
"service", name,
"from", from.String(),
"to", to.String(),
)
},
})
return func(c *gin.Context) {
_, err := cb.Execute(func() (struct{}, error) {
c.Next()
// 下游 5xx 视为熔断失败
if c.Writer.Status() >= 500 {
return struct{}{}, errors.New("downstream_error")
}
return struct{}{}, nil
})
if err != nil {
// 熔断打开或半开探测名额已满:返回 503
if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) {
c.Header("Retry-After", "30")
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": gin.H{
"code": "GW_CIRCUIT_OPEN",
"message": "upstream circuit breaker open",
},
"retry_after": 30,
})
return
}
// 其他情况(下游已写 5xx 响应):响应已写入,不覆盖
return
}
}
}