feat(routing): add route health admin view

This commit is contained in:
phamnazage-jpg
2026-05-29 13:37:43 +08:00
parent dbfd2ba11c
commit 2896e62071
10 changed files with 1380 additions and 0 deletions

View File

@@ -51,6 +51,7 @@ type ActionSet struct {
ListRouteFailoverEvents func(context.Context, ListRouteFailoverEventsRequest) ([]RouteFailoverEventInfo, error)
AppendRouteStickyAudit func(context.Context, AppendRouteStickyAuditRequest) (RouteStickyAuditInfo, error)
ListRouteStickyAudit func(context.Context, ListRouteStickyAuditRequest) ([]RouteStickyAuditInfo, error)
ListRouteHealth func(context.Context, ListRouteHealthRequest) ([]RouteHealthInfo, error)
ResolveRoute func(context.Context, ResolveRouteRequest) (ResolveRouteInfo, error)
RouteChatCompletions func(context.Context, RouteChatCompletionsRequest) (RouteChatCompletionsResult, error)
ProxyRouteChatCompletions func(context.Context, ProxyRouteChatCompletionsRequest) (ProxyRouteChatCompletionsResult, error)
@@ -401,6 +402,9 @@ func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet) http.Ha
mux.Handle("GET /api/routing/logs/sticky-audit", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListRouteStickyAudit(w, r, actions.ListRouteStickyAudit)
})))
mux.Handle("GET /api/routing/routes/health", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListRouteHealth(w, r, actions.ListRouteHealth)
})))
mux.Handle("POST /api/routing/resolve", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleResolveRoute(w, r, actions.ResolveRoute)
})))
@@ -1266,6 +1270,7 @@ func NewActionSetWithStickyRuntime(sqliteDSN string, stickyRuntime stickyStoreRu
ListRouteFailoverEvents: buildListRouteFailoverEventsAction(sqliteDSN),
AppendRouteStickyAudit: buildAppendRouteStickyAuditAction(routeLogWriter, sqliteDSN),
ListRouteStickyAudit: buildListRouteStickyAuditAction(sqliteDSN),
ListRouteHealth: buildListRouteHealthAction(sqliteDSN, stickyRuntime),
ResolveRoute: resolveRoute,
RouteChatCompletions: routeChatCompletions,
ProxyRouteChatCompletions: proxyRouteChatCompletions,

View File

@@ -0,0 +1,295 @@
package app
import (
"context"
"database/sql"
"fmt"
"net/http"
"strings"
"sub2api-cn-relay-manager/internal/routing"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
const (
routeRuntimeStatusHealthy = "healthy"
routeRuntimeStatusCooldown = "cooldown"
routeRuntimeStatusFailing = "failing"
routeRuntimeStatusDisabled = "disabled"
defaultRouteHealthFailoverLimit = 20
)
type ListRouteHealthRequest struct {
LogicalGroupID string
RouteID string
Status string
}
type RouteHealthInfo struct {
Backend string `json:"backend"`
RouteID string `json:"route_id"`
RouteName string `json:"route_name,omitempty"`
LogicalGroupID string `json:"logical_group_id"`
LogicalGroupDisplayName string `json:"logical_group_display_name,omitempty"`
LogicalGroupStatus string `json:"logical_group_status,omitempty"`
ConfiguredStatus string `json:"configured_status,omitempty"`
ShadowHostID string `json:"shadow_host_id"`
ShadowGroupID string `json:"shadow_group_id"`
Priority int `json:"priority"`
Weight int `json:"weight,omitempty"`
RuntimeStatus string `json:"runtime_status"`
FailureCount int `json:"failure_count"`
CooldownUntil string `json:"cooldown_until,omitempty"`
CooldownReason string `json:"cooldown_reason,omitempty"`
LastErrorClass string `json:"last_error_class,omitempty"`
LastSelectedAt string `json:"last_selected_at,omitempty"`
LastUpstreamStatus int `json:"last_upstream_status,omitempty"`
LastRequestID string `json:"last_request_id,omitempty"`
LastPublicModel string `json:"last_public_model,omitempty"`
RecentFailoverCount int `json:"recent_failover_count"`
UpstreamBaseURLHint string `json:"upstream_base_url_hint,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
func handleListRouteHealth(w http.ResponseWriter, r *http.Request, fn func(context.Context, ListRouteHealthRequest) ([]RouteHealthInfo, error)) {
if fn == nil {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-route-health action is not configured"})
return
}
req, err := decodeListRouteHealthRequest(r)
if err != nil {
writeHTTPError(w, err)
return
}
items, actionErr := fn(r.Context(), req)
if actionErr != nil {
writeHTTPError(w, classifyError(actionErr))
return
}
writeJSON(w, http.StatusOK, map[string]any{"route_health": items})
}
func buildListRouteHealthAction(sqliteDSN string, stickyRuntime stickyStoreRuntime) func(context.Context, ListRouteHealthRequest) ([]RouteHealthInfo, error) {
return func(ctx context.Context, req ListRouteHealthRequest) ([]RouteHealthInfo, error) {
store, err := sqlite.Open(ctx, sqliteDSN)
if err != nil {
return nil, err
}
defer store.Close()
groups, routes, err := loadRouteHealthScope(ctx, store, req)
if err != nil {
return nil, err
}
items := make([]RouteHealthInfo, 0, len(routes))
for _, route := range routes {
group, ok := groups[route.LogicalGroupID]
if !ok {
return nil, fmt.Errorf("logical group %q not found for route %q", route.LogicalGroupID, route.RouteID)
}
item, err := buildRouteHealthInfo(ctx, store, stickyRuntime, group, route)
if err != nil {
return nil, err
}
if req.Status != "" && !strings.EqualFold(item.RuntimeStatus, req.Status) {
continue
}
items = append(items, item)
}
return items, nil
}
}
func decodeListRouteHealthRequest(r *http.Request) (ListRouteHealthRequest, *httpError) {
status := strings.TrimSpace(r.URL.Query().Get("status"))
if status != "" && !isSupportedRouteHealthStatus(status) {
return ListRouteHealthRequest{}, &httpError{
StatusCode: http.StatusBadRequest,
Code: "bad_request",
Message: fmt.Sprintf("unsupported route health status %q", status),
}
}
return ListRouteHealthRequest{
LogicalGroupID: strings.TrimSpace(r.URL.Query().Get("logical_group_id")),
RouteID: strings.TrimSpace(r.URL.Query().Get("route_id")),
Status: strings.ToLower(status),
}, nil
}
func loadRouteHealthScope(ctx context.Context, store *sqlite.DB, req ListRouteHealthRequest) (map[string]sqlite.LogicalGroup, []sqlite.LogicalGroupRoute, error) {
if req.RouteID != "" {
route, err := store.LogicalGroupRoutes().GetByRouteID(ctx, req.RouteID)
if err != nil {
if err == sql.ErrNoRows {
return map[string]sqlite.LogicalGroup{}, nil, nil
}
return nil, nil, err
}
if req.LogicalGroupID != "" && route.LogicalGroupID != req.LogicalGroupID {
return map[string]sqlite.LogicalGroup{}, nil, nil
}
group, err := store.LogicalGroups().GetByLogicalGroupID(ctx, route.LogicalGroupID)
if err != nil {
return nil, nil, err
}
return map[string]sqlite.LogicalGroup{group.LogicalGroupID: group}, []sqlite.LogicalGroupRoute{route}, nil
}
if req.LogicalGroupID != "" {
group, err := store.LogicalGroups().GetByLogicalGroupID(ctx, req.LogicalGroupID)
if err != nil {
return nil, nil, err
}
routes, err := store.LogicalGroupRoutes().ListByLogicalGroupID(ctx, req.LogicalGroupID)
if err != nil {
return nil, nil, err
}
return map[string]sqlite.LogicalGroup{group.LogicalGroupID: group}, routes, nil
}
groupRows, err := store.LogicalGroups().List(ctx)
if err != nil {
return nil, nil, err
}
groupMap := make(map[string]sqlite.LogicalGroup, len(groupRows))
routes := make([]sqlite.LogicalGroupRoute, 0)
for _, group := range groupRows {
groupMap[group.LogicalGroupID] = group
groupRoutes, err := store.LogicalGroupRoutes().ListByLogicalGroupID(ctx, group.LogicalGroupID)
if err != nil {
return nil, nil, err
}
routes = append(routes, groupRoutes...)
}
return groupMap, routes, nil
}
func buildRouteHealthInfo(ctx context.Context, store *sqlite.DB, stickyRuntime stickyStoreRuntime, group sqlite.LogicalGroup, route sqlite.LogicalGroupRoute) (RouteHealthInfo, error) {
failureState, hasFailure, err := stickyRuntime.store.GetRouteFailure(ctx, route.RouteID)
if err != nil {
return RouteHealthInfo{}, err
}
cooldownState, hasCooldown, err := stickyRuntime.store.GetCooldown(ctx, route.RouteID)
if err != nil {
return RouteHealthInfo{}, err
}
// 兼容 route 表上的手工 cooldown 标记,避免健康页漏掉已配置的禁用窗口。
if !hasCooldown && !routeExitsCooldown(route.CooldownUntil) {
hasCooldown = true
cooldownState = routing.RouteCooldownState{
RouteID: route.RouteID,
Reason: "configured_cooldown",
Until: route.CooldownUntil,
}
}
decisionRows, err := store.RouteDecisionLogs().ListRecent(ctx, sqlite.RouteDecisionLogFilter{
SelectedRouteID: route.RouteID,
Limit: 1,
})
if err != nil {
return RouteHealthInfo{}, err
}
failoverFrom, err := store.RouteFailoverEvents().ListRecent(ctx, sqlite.RouteFailoverEventFilter{
FromRouteID: route.RouteID,
Limit: defaultRouteHealthFailoverLimit,
})
if err != nil {
return RouteHealthInfo{}, err
}
failoverTo, err := store.RouteFailoverEvents().ListRecent(ctx, sqlite.RouteFailoverEventFilter{
ToRouteID: route.RouteID,
Limit: defaultRouteHealthFailoverLimit,
})
if err != nil {
return RouteHealthInfo{}, err
}
item := RouteHealthInfo{
Backend: stickyRuntime.backend,
RouteID: route.RouteID,
RouteName: route.Name,
LogicalGroupID: route.LogicalGroupID,
LogicalGroupDisplayName: group.DisplayName,
LogicalGroupStatus: group.Status,
ConfiguredStatus: route.Status,
ShadowHostID: route.ShadowHostID,
ShadowGroupID: route.ShadowGroupID,
Priority: route.Priority,
Weight: route.Weight,
RuntimeStatus: deriveRouteRuntimeStatus(group.Status, route.Status, hasCooldown, failureCountFromState(failureState, hasFailure)),
FailureCount: failureCountFromState(failureState, hasFailure),
CooldownUntil: cooldownUntilFromState(cooldownState, hasCooldown),
CooldownReason: cooldownReasonFromState(cooldownState, hasCooldown),
LastErrorClass: lastErrorClassFromStates(failureState, hasFailure, decisionRows),
RecentFailoverCount: len(failoverFrom) + len(failoverTo),
UpstreamBaseURLHint: route.UpstreamBaseURLHint,
UpdatedAt: route.UpdatedAt,
}
if len(decisionRows) > 0 {
item.LastSelectedAt = decisionRows[0].CreatedAt
item.LastUpstreamStatus = decisionRows[0].UpstreamStatus
item.LastRequestID = decisionRows[0].RequestID
item.LastPublicModel = decisionRows[0].PublicModel
}
return item, nil
}
func deriveRouteRuntimeStatus(groupStatus, routeStatus string, hasCooldown bool, failureCount int) string {
if !isActiveStatus(groupStatus) || !isActiveStatus(routeStatus) {
return routeRuntimeStatusDisabled
}
if hasCooldown {
return routeRuntimeStatusCooldown
}
if failureCount > 0 {
return routeRuntimeStatusFailing
}
return routeRuntimeStatusHealthy
}
func failureCountFromState(state routing.RouteFailureState, ok bool) int {
if !ok {
return 0
}
return state.FailureCount
}
func cooldownUntilFromState(state routing.RouteCooldownState, ok bool) string {
if !ok {
return ""
}
return strings.TrimSpace(state.Until)
}
func cooldownReasonFromState(state routing.RouteCooldownState, ok bool) string {
if !ok {
return ""
}
return strings.TrimSpace(state.Reason)
}
func lastErrorClassFromStates(state routing.RouteFailureState, hasFailure bool, decisions []sqlite.RouteDecisionLog) string {
if hasFailure && strings.TrimSpace(state.LastErrorClass) != "" {
return strings.TrimSpace(state.LastErrorClass)
}
if len(decisions) == 0 {
return ""
}
return strings.TrimSpace(decisions[0].ErrorClass)
}
func isSupportedRouteHealthStatus(status string) bool {
switch strings.ToLower(strings.TrimSpace(status)) {
case routeRuntimeStatusHealthy, routeRuntimeStatusCooldown, routeRuntimeStatusFailing, routeRuntimeStatusDisabled:
return true
default:
return false
}
}

View File

@@ -0,0 +1,160 @@
package app
import (
"context"
"net/http"
"path/filepath"
"testing"
)
func TestAPIListRouteHealthRejectsInvalidStatus(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
ListRouteHealth: func(context.Context, ListRouteHealthRequest) ([]RouteHealthInfo, error) {
t.Fatal("ListRouteHealth should not be called")
return nil, nil
},
})
request := httptestRequest(t, http.MethodGet, "/api/routing/routes/health?status=broken", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusBadRequest)
assertJSONContains(t, response.Body().Bytes(), "error.code", "bad_request")
}
func TestNewActionSetRouteHealthFlow(t *testing.T) {
dsn := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "route-health.db")) + "?_busy_timeout=5000"
actions := NewActionSet(dsn)
ctx := context.Background()
if _, err := actions.CreateLogicalGroup(ctx, CreateLogicalGroupRequest{
LogicalGroupID: "gpt-shared",
DisplayName: "GPT Shared",
Status: "active",
RoutePolicy: "priority",
StickyMode: "conversation_preferred",
ConversationTTLSeconds: 1200,
UserModelTTLSeconds: 600,
FailoverThreshold: 2,
CooldownSeconds: 300,
}); err != nil {
t.Fatalf("CreateLogicalGroup(gpt-shared) error = %v", err)
}
createRoute := func(routeID, name, status, shadowGroup string, priority int) {
t.Helper()
if _, err := actions.CreateLogicalGroupRoute(ctx, CreateLogicalGroupRouteRequest{
LogicalGroupID: "gpt-shared",
RouteID: routeID,
Name: name,
Status: status,
Priority: priority,
ShadowGroupID: shadowGroup,
ShadowHostID: "remote43",
UpstreamBaseURLHint: "https://example.com/v1",
}); err != nil {
t.Fatalf("CreateLogicalGroupRoute(%s) error = %v", routeID, err)
}
}
createRoute("healthy-route", "Healthy", "active", "shadow-healthy", 10)
createRoute("failing-route", "Failing", "active", "shadow-failing", 20)
createRoute("cooldown-route", "Cooldown", "active", "shadow-cooldown", 30)
createRoute("disabled-route", "Disabled", "disabled", "shadow-disabled", 40)
if _, err := actions.AppendRouteDecisionLog(ctx, AppendRouteDecisionLogRequest{
RequestID: "req-healthy",
LogicalGroupID: "gpt-shared",
PublicModel: "gpt-5.4",
SelectedRouteID: "healthy-route",
SelectedShadowGroupID: "shadow-healthy",
UpstreamStatus: 200,
Sync: true,
}); err != nil {
t.Fatalf("AppendRouteDecisionLog(healthy) error = %v", err)
}
if _, err := actions.AppendRouteDecisionLog(ctx, AppendRouteDecisionLogRequest{
RequestID: "req-failing",
LogicalGroupID: "gpt-shared",
PublicModel: "gpt-5.4",
SelectedRouteID: "failing-route",
SelectedShadowGroupID: "shadow-failing",
ErrorClass: "upstream_5xx",
UpstreamStatus: 503,
Sync: true,
}); err != nil {
t.Fatalf("AppendRouteDecisionLog(failing) error = %v", err)
}
if _, err := actions.AppendRouteFailoverEvent(ctx, AppendRouteFailoverEventRequest{
RequestID: "req-failover",
LogicalGroupID: "gpt-shared",
PublicModel: "gpt-5.4",
FromRouteID: "failing-route",
ToRouteID: "healthy-route",
Reason: "failure_threshold_exceeded:timeout",
FailureCount: 2,
Sync: true,
}); err != nil {
t.Fatalf("AppendRouteFailoverEvent() error = %v", err)
}
if _, err := actions.SetRouteFailure(ctx, SetRouteFailureRequest{
RouteID: "failing-route",
FailureCount: 2,
LastErrorClass: "timeout",
TTLSeconds: 600,
}); err != nil {
t.Fatalf("SetRouteFailure() error = %v", err)
}
if _, err := actions.SetRouteCooldown(ctx, SetRouteCooldownRequest{
RouteID: "cooldown-route",
Reason: "degraded",
TTLSeconds: 600,
}); err != nil {
t.Fatalf("SetRouteCooldown() error = %v", err)
}
items, err := actions.ListRouteHealth(ctx, ListRouteHealthRequest{})
if err != nil {
t.Fatalf("ListRouteHealth() error = %v", err)
}
if len(items) != 4 {
t.Fatalf("ListRouteHealth() len = %d, want 4", len(items))
}
byRoute := make(map[string]RouteHealthInfo, len(items))
for _, item := range items {
byRoute[item.RouteID] = item
}
if got := byRoute["healthy-route"]; got.RuntimeStatus != routeRuntimeStatusHealthy || got.LastUpstreamStatus != 200 {
t.Fatalf("healthy-route = %+v, want healthy with upstream 200", got)
}
if got := byRoute["failing-route"]; got.RuntimeStatus != routeRuntimeStatusFailing || got.FailureCount != 2 || got.LastErrorClass != "timeout" || got.RecentFailoverCount != 1 || got.LastUpstreamStatus != 503 {
t.Fatalf("failing-route = %+v, want failing with failure_count=2 recent_failover_count=1 upstream=503", got)
}
if got := byRoute["cooldown-route"]; got.RuntimeStatus != routeRuntimeStatusCooldown || got.CooldownReason != "degraded" || got.CooldownUntil == "" {
t.Fatalf("cooldown-route = %+v, want cooldown with reason degraded", got)
}
if got := byRoute["disabled-route"]; got.RuntimeStatus != routeRuntimeStatusDisabled {
t.Fatalf("disabled-route = %+v, want disabled", got)
}
failingOnly, err := actions.ListRouteHealth(ctx, ListRouteHealthRequest{Status: routeRuntimeStatusFailing})
if err != nil {
t.Fatalf("ListRouteHealth(failing) error = %v", err)
}
if len(failingOnly) != 1 || failingOnly[0].RouteID != "failing-route" {
t.Fatalf("ListRouteHealth(failing) = %+v, want only failing-route", failingOnly)
}
oneRoute, err := actions.ListRouteHealth(ctx, ListRouteHealthRequest{RouteID: "cooldown-route"})
if err != nil {
t.Fatalf("ListRouteHealth(route_id) error = %v", err)
}
if len(oneRoute) != 1 || oneRoute[0].RouteID != "cooldown-route" {
t.Fatalf("ListRouteHealth(route_id) = %+v, want only cooldown-route", oneRoute)
}
}