feat(routing): add sticky-backed route resolver

This commit is contained in:
phamnazage-jpg
2026-05-29 09:38:59 +08:00
parent 932db59638
commit 66ad319ccb
3 changed files with 595 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)
ResolveRoute func(context.Context, ResolveRouteRequest) (ResolveRouteInfo, error)
SetStickyBinding func(context.Context, SetStickyBindingRequest) (StickyBindingInfo, error)
GetStickyBinding func(context.Context, GetStickyBindingRequest) (StickyBindingInfo, error)
SetRouteFailure func(context.Context, SetRouteFailureRequest) (RouteFailureInfo, error)
@@ -398,6 +399,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("POST /api/routing/resolve", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleResolveRoute(w, r, actions.ResolveRoute)
})))
mux.Handle("POST /api/routing/sticky/bindings", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleSetStickyBinding(w, r, actions.SetStickyBinding)
})))
@@ -1225,6 +1229,7 @@ func NewActionSet(sqliteDSN string) ActionSet {
func NewActionSetWithStickyRuntime(sqliteDSN string, stickyRuntime stickyStoreRuntime) ActionSet {
routeLogWriter := newLazyRouteLogWriter(sqliteDSN)
resolveRoute := buildResolveRouteAction(sqliteDSN, stickyRuntime, routeLogWriter)
return ActionSet{
CreateBatchImportRun: buildCreateBatchImportRunAction(sqliteDSN),
ListBatchImportRuns: buildListBatchImportRunsAction(sqliteDSN),
@@ -1251,6 +1256,7 @@ func NewActionSetWithStickyRuntime(sqliteDSN string, stickyRuntime stickyStoreRu
ListRouteFailoverEvents: buildListRouteFailoverEventsAction(sqliteDSN),
AppendRouteStickyAudit: buildAppendRouteStickyAuditAction(routeLogWriter, sqliteDSN),
ListRouteStickyAudit: buildListRouteStickyAuditAction(sqliteDSN),
ResolveRoute: resolveRoute,
SetStickyBinding: buildSetStickyBindingAction(stickyRuntime),
GetStickyBinding: buildGetStickyBindingAction(stickyRuntime),
SetRouteFailure: buildSetRouteFailureAction(stickyRuntime),

View File

@@ -0,0 +1,404 @@
package app
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"sub2api-cn-relay-manager/internal/routing"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
type ResolveRouteRequest struct {
RequestID string `json:"request_id,omitempty"`
LogicalGroupID string `json:"logical_group_id"`
PublicModel string `json:"public_model"`
Scope string `json:"scope"`
SubjectID string `json:"subject_id"`
UserKey string `json:"user_key,omitempty"`
ConversationKey string `json:"conversation_key,omitempty"`
Sync bool `json:"sync,omitempty"`
}
type ResolveRouteInfo struct {
RequestID string `json:"request_id"`
Backend string `json:"backend"`
LogicalGroupID string `json:"logical_group_id"`
PublicModel string `json:"public_model"`
Scope string `json:"scope"`
SubjectID string `json:"subject_id"`
StickyKey string `json:"sticky_key"`
StickyHit bool `json:"sticky_hit"`
StickyAction string `json:"sticky_action"`
RouteID string `json:"route_id"`
RouteName string `json:"route_name,omitempty"`
ShadowGroupID string `json:"shadow_group_id"`
ShadowHostID string `json:"shadow_host_id"`
ShadowModel string `json:"shadow_model,omitempty"`
Priority int `json:"priority"`
Weight int `json:"weight"`
BoundAt string `json:"bound_at,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
type resolvedRouteCandidate struct {
route sqlite.LogicalGroupRoute
routeModel sqlite.LogicalGroupRouteModel
}
func handleResolveRoute(w http.ResponseWriter, r *http.Request, fn func(context.Context, ResolveRouteRequest) (ResolveRouteInfo, error)) {
if fn == nil {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "resolve-route action is not configured"})
return
}
var req ResolveRouteRequest
if err := decodeJSON(r, &req); err != nil {
writeHTTPError(w, err)
return
}
info, err := fn(r.Context(), req)
if err != nil {
writeHTTPError(w, classifyError(err))
return
}
writeJSON(w, http.StatusOK, map[string]any{"resolve": info})
}
func buildResolveRouteAction(sqliteDSN string, stickyRuntime stickyStoreRuntime, writerSource *lazyRouteLogWriter) func(context.Context, ResolveRouteRequest) (ResolveRouteInfo, error) {
return func(ctx context.Context, req ResolveRouteRequest) (ResolveRouteInfo, error) {
store, err := sqlite.Open(ctx, sqliteDSN)
if err != nil {
return ResolveRouteInfo{}, err
}
defer store.Close()
group, err := getLogicalGroupRow(ctx, store, req.LogicalGroupID)
if err != nil {
return ResolveRouteInfo{}, err
}
if !isActiveStatus(group.Status) {
return ResolveRouteInfo{}, fmt.Errorf("logical group %q is not active", group.LogicalGroupID)
}
req, stickyKey, requestID, err := normalizeResolveRouteRequest(req)
if err != nil {
return ResolveRouteInfo{}, err
}
writer, err := writerSource.get(ctx)
if err != nil {
return ResolveRouteInfo{}, err
}
binding, stickyHit, err := lookupValidStickyBinding(ctx, store, stickyRuntime, stickyKey, req)
if err != nil {
return ResolveRouteInfo{}, err
}
if stickyHit {
candidate, err := loadResolvedRouteCandidate(ctx, store, group.LogicalGroupID, req.PublicModel, binding.RouteID)
if err != nil {
if err := stickyRuntime.store.Delete(ctx, stickyKey); err != nil {
return ResolveRouteInfo{}, err
}
} else {
info := resolveRouteInfoFromBinding(stickyRuntime.backend, stickyKey, req.Scope, req.SubjectID, candidate, binding, requestID, true, "hit")
if err := writer.AppendStickyAudit(ctx, routing.RouteStickyAuditEvent{
StickyKey: stickyKey,
StickyKeyType: req.Scope,
LogicalGroupID: req.LogicalGroupID,
PublicModel: req.PublicModel,
RouteID: candidate.route.RouteID,
Action: "hit",
ExpiresAt: binding.ExpiresAt,
}); err != nil {
return ResolveRouteInfo{}, err
}
if err := writer.AppendDecision(ctx, routing.RouteDecisionEvent{
RequestID: requestID,
LogicalGroupID: req.LogicalGroupID,
PublicModel: req.PublicModel,
UserKey: resolveUserKey(req),
ConversationKey: resolveConversationKey(req),
StickyKey: stickyKey,
StickyKeyType: req.Scope,
StickyHit: true,
SelectedRouteID: candidate.route.RouteID,
SelectedShadowGroupID: candidate.route.ShadowGroupID,
}); err != nil {
return ResolveRouteInfo{}, err
}
if req.Sync {
if err := writer.Flush(ctx); err != nil {
return ResolveRouteInfo{}, err
}
}
return info, nil
}
}
candidate, err := selectResolvedRouteCandidate(ctx, store, stickyRuntime, group, req.PublicModel)
if err != nil {
return ResolveRouteInfo{}, err
}
ttl, err := resolveStickyTTL(group, req.Scope)
if err != nil {
return ResolveRouteInfo{}, err
}
binding = routing.StickyBinding{
LogicalGroupID: req.LogicalGroupID,
PublicModel: req.PublicModel,
RouteID: candidate.route.RouteID,
ShadowGroupID: candidate.route.ShadowGroupID,
}
if err := stickyRuntime.store.Set(ctx, stickyKey, binding, ttl); err != nil {
return ResolveRouteInfo{}, err
}
stored, ok, err := stickyRuntime.store.Get(ctx, stickyKey)
if err != nil {
return ResolveRouteInfo{}, err
}
if !ok {
return ResolveRouteInfo{}, fmt.Errorf("sticky binding %q not found after set", stickyKey)
}
if err := writer.AppendStickyAudit(ctx, routing.RouteStickyAuditEvent{
StickyKey: stickyKey,
StickyKeyType: req.Scope,
LogicalGroupID: req.LogicalGroupID,
PublicModel: req.PublicModel,
RouteID: candidate.route.RouteID,
Action: "bind",
ExpiresAt: stored.ExpiresAt,
}); err != nil {
return ResolveRouteInfo{}, err
}
if err := writer.AppendDecision(ctx, routing.RouteDecisionEvent{
RequestID: requestID,
LogicalGroupID: req.LogicalGroupID,
PublicModel: req.PublicModel,
UserKey: resolveUserKey(req),
ConversationKey: resolveConversationKey(req),
StickyKey: stickyKey,
StickyKeyType: req.Scope,
StickyHit: false,
SelectedRouteID: candidate.route.RouteID,
SelectedShadowGroupID: candidate.route.ShadowGroupID,
}); err != nil {
return ResolveRouteInfo{}, err
}
if req.Sync {
if err := writer.Flush(ctx); err != nil {
return ResolveRouteInfo{}, err
}
}
return resolveRouteInfoFromBinding(stickyRuntime.backend, stickyKey, req.Scope, req.SubjectID, candidate, stored, requestID, false, "bind"), nil
}
}
func normalizeResolveRouteRequest(req ResolveRouteRequest) (ResolveRouteRequest, string, string, error) {
req.RequestID = strings.TrimSpace(req.RequestID)
req.LogicalGroupID = strings.TrimSpace(req.LogicalGroupID)
req.PublicModel = strings.TrimSpace(req.PublicModel)
req.Scope = strings.TrimSpace(req.Scope)
req.SubjectID = strings.TrimSpace(req.SubjectID)
req.UserKey = strings.TrimSpace(req.UserKey)
req.ConversationKey = strings.TrimSpace(req.ConversationKey)
if req.LogicalGroupID == "" {
return ResolveRouteRequest{}, "", "", fmt.Errorf("logical_group_id is required")
}
if req.PublicModel == "" {
return ResolveRouteRequest{}, "", "", fmt.Errorf("public_model is required")
}
if req.Scope == "" {
return ResolveRouteRequest{}, "", "", fmt.Errorf("scope is required")
}
if req.SubjectID == "" {
return ResolveRouteRequest{}, "", "", fmt.Errorf("subject_id is required")
}
stickyKey, err := routing.BuildStickyKey(req.Scope, req.LogicalGroupID, req.PublicModel, req.SubjectID)
if err != nil {
return ResolveRouteRequest{}, "", "", err
}
requestID := req.RequestID
if requestID == "" {
requestID = fmt.Sprintf("resolve_%d", time.Now().UnixNano())
}
return req, stickyKey, requestID, nil
}
func lookupValidStickyBinding(ctx context.Context, store *sqlite.DB, runtime stickyStoreRuntime, stickyKey string, req ResolveRouteRequest) (routing.StickyBinding, bool, error) {
binding, ok, err := runtime.store.Get(ctx, stickyKey)
if err != nil || !ok {
return routing.StickyBinding{}, false, err
}
candidate, err := loadResolvedRouteCandidate(ctx, store, req.LogicalGroupID, req.PublicModel, binding.RouteID)
if err != nil {
if deleteErr := runtime.store.Delete(ctx, stickyKey); deleteErr != nil {
return routing.StickyBinding{}, false, deleteErr
}
return routing.StickyBinding{}, false, nil
}
cooldown, ok, err := runtime.store.GetCooldown(ctx, candidate.route.RouteID)
if err != nil {
return routing.StickyBinding{}, false, err
}
if ok && !isExpiredRFC3339(cooldown.Until) {
if deleteErr := runtime.store.Delete(ctx, stickyKey); deleteErr != nil {
return routing.StickyBinding{}, false, deleteErr
}
return routing.StickyBinding{}, false, nil
}
return binding, true, nil
}
func selectResolvedRouteCandidate(ctx context.Context, store *sqlite.DB, runtime stickyStoreRuntime, group sqlite.LogicalGroup, publicModel string) (resolvedRouteCandidate, error) {
models, err := store.LogicalGroupModels().ListByLogicalGroupID(ctx, group.LogicalGroupID)
if err != nil {
return resolvedRouteCandidate{}, err
}
if !logicalGroupHasActiveModel(models, publicModel) {
return resolvedRouteCandidate{}, fmt.Errorf("logical group %q does not expose active model %q", group.LogicalGroupID, publicModel)
}
routes, err := store.LogicalGroupRoutes().ListByLogicalGroupID(ctx, group.LogicalGroupID)
if err != nil {
return resolvedRouteCandidate{}, err
}
for _, route := range routes {
if !isActiveStatus(route.Status) {
continue
}
if !isExpiredRFC3339(route.CooldownUntil) {
continue
}
cooldown, ok, err := runtime.store.GetCooldown(ctx, route.RouteID)
if err != nil {
return resolvedRouteCandidate{}, err
}
if ok && !isExpiredRFC3339(cooldown.Until) {
continue
}
routeModels, err := store.LogicalGroupRouteModels().ListByRouteID(ctx, route.RouteID)
if err != nil {
return resolvedRouteCandidate{}, err
}
for _, routeModel := range routeModels {
if strings.TrimSpace(routeModel.PublicModel) == publicModel && isActiveStatus(routeModel.Status) {
return resolvedRouteCandidate{route: route, routeModel: routeModel}, nil
}
}
}
return resolvedRouteCandidate{}, fmt.Errorf("no active route found for logical group %q model %q", group.LogicalGroupID, publicModel)
}
func loadResolvedRouteCandidate(ctx context.Context, store *sqlite.DB, logicalGroupID string, publicModel string, routeID string) (resolvedRouteCandidate, error) {
route, err := getLogicalGroupRouteRow(ctx, store, routeID)
if err != nil {
return resolvedRouteCandidate{}, err
}
if route.LogicalGroupID != logicalGroupID {
return resolvedRouteCandidate{}, fmt.Errorf("logical group route %q not found under logical group %q", route.RouteID, logicalGroupID)
}
if !isActiveStatus(route.Status) {
return resolvedRouteCandidate{}, fmt.Errorf("logical group route %q is not active", route.RouteID)
}
if !isExpiredRFC3339(route.CooldownUntil) {
return resolvedRouteCandidate{}, fmt.Errorf("logical group route %q is cooling down", route.RouteID)
}
routeModels, err := store.LogicalGroupRouteModels().ListByRouteID(ctx, route.RouteID)
if err != nil {
return resolvedRouteCandidate{}, err
}
for _, routeModel := range routeModels {
if strings.TrimSpace(routeModel.PublicModel) == strings.TrimSpace(publicModel) && isActiveStatus(routeModel.Status) {
return resolvedRouteCandidate{route: route, routeModel: routeModel}, nil
}
}
return resolvedRouteCandidate{}, fmt.Errorf("logical group route %q does not expose active model %q", route.RouteID, publicModel)
}
func logicalGroupHasActiveModel(models []sqlite.LogicalGroupModel, publicModel string) bool {
publicModel = strings.TrimSpace(publicModel)
for _, model := range models {
if strings.TrimSpace(model.PublicModel) == publicModel && isActiveStatus(model.Status) {
return true
}
}
return false
}
func resolveStickyTTL(group sqlite.LogicalGroup, scope string) (time.Duration, error) {
switch strings.ToLower(strings.TrimSpace(scope)) {
case routing.StickyScopeConversation, routing.StickyScopeSession:
return secondsToDuration(group.ConversationTTLSeconds, 0)
case routing.StickyScopeUser:
return secondsToDuration(group.UserModelTTLSeconds, 0)
default:
return 0, fmt.Errorf("unsupported sticky scope %q", scope)
}
}
func resolveRouteInfoFromBinding(backend string, stickyKey string, scope string, subjectID string, candidate resolvedRouteCandidate, binding routing.StickyBinding, requestID string, stickyHit bool, stickyAction string) ResolveRouteInfo {
return ResolveRouteInfo{
RequestID: requestID,
Backend: backend,
LogicalGroupID: binding.LogicalGroupID,
PublicModel: binding.PublicModel,
Scope: strings.TrimSpace(scope),
SubjectID: strings.TrimSpace(subjectID),
StickyKey: stickyKey,
StickyHit: stickyHit,
StickyAction: stickyAction,
RouteID: candidate.route.RouteID,
RouteName: candidate.route.Name,
ShadowGroupID: candidate.route.ShadowGroupID,
ShadowHostID: candidate.route.ShadowHostID,
ShadowModel: candidate.routeModel.ShadowModel,
Priority: candidate.route.Priority,
Weight: candidate.route.Weight,
BoundAt: binding.BoundAt,
ExpiresAt: binding.ExpiresAt,
}
}
func resolveUserKey(req ResolveRouteRequest) string {
if req.UserKey != "" {
return req.UserKey
}
if strings.EqualFold(req.Scope, routing.StickyScopeUser) {
return req.SubjectID
}
return ""
}
func resolveConversationKey(req ResolveRouteRequest) string {
if req.ConversationKey != "" {
return req.ConversationKey
}
if strings.EqualFold(req.Scope, routing.StickyScopeConversation) || strings.EqualFold(req.Scope, routing.StickyScopeSession) {
return req.SubjectID
}
return ""
}
func isActiveStatus(status string) bool {
return strings.EqualFold(strings.TrimSpace(status), "active")
}
func isExpiredRFC3339(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return true
}
until, err := time.Parse(time.RFC3339, raw)
if err != nil {
return true
}
return !until.After(time.Now().UTC())
}

View File

@@ -0,0 +1,185 @@
package app
import (
"context"
"net/http"
"path/filepath"
"testing"
)
func TestAPIResolveRouteReturnsSelectedRoute(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
ResolveRoute: func(_ context.Context, req ResolveRouteRequest) (ResolveRouteInfo, error) {
if req.LogicalGroupID != "gpt-shared" {
t.Fatalf("LogicalGroupID = %q, want gpt-shared", req.LogicalGroupID)
}
return ResolveRouteInfo{
RequestID: "req-1",
Backend: "memory",
LogicalGroupID: req.LogicalGroupID,
PublicModel: req.PublicModel,
StickyKey: "lg:gpt-shared:m:gpt-5.4:conv:conv-1",
StickyHit: false,
StickyAction: "bind",
RouteID: "asxs",
ShadowGroupID: "gpt-shared__asxs",
ShadowHostID: "remote43",
ShadowModel: "gpt-5.4",
}, nil
},
})
request := httptestRequest(t, http.MethodPost, "/api/routing/resolve", map[string]any{
"logical_group_id": "gpt-shared",
"public_model": "gpt-5.4",
"scope": "conversation",
"subject_id": "conv-1",
"sync": true,
}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "resolve.route_id", "asxs")
assertJSONContains(t, response.Body().Bytes(), "resolve.sticky_action", "bind")
}
func TestNewActionSetResolveRouteFlow(t *testing.T) {
dsn := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "route-resolve.db")) + "?_busy_timeout=5000"
actions := NewActionSet(dsn)
ctx := context.Background()
_, 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,
})
if err != nil {
t.Fatalf("CreateLogicalGroup() error = %v", err)
}
if _, err := actions.CreateLogicalGroupModel(ctx, CreateLogicalGroupModelRequest{
LogicalGroupID: "gpt-shared",
PublicModel: "gpt-5.4",
Status: "active",
}); err != nil {
t.Fatalf("CreateLogicalGroupModel() error = %v", err)
}
if _, err := actions.CreateLogicalGroupRoute(ctx, CreateLogicalGroupRouteRequest{
LogicalGroupID: "gpt-shared",
RouteID: "asxs",
Name: "ASXS",
Status: "active",
Priority: 20,
ShadowGroupID: "gpt-shared__asxs",
ShadowHostID: "remote43",
UpstreamBaseURLHint: "https://api.asxs.top/v1",
}); err != nil {
t.Fatalf("CreateLogicalGroupRoute(asxs) error = %v", err)
}
if _, err := actions.CreateLogicalGroupRouteModel(ctx, CreateLogicalGroupRouteModelRequest{
LogicalGroupID: "gpt-shared",
RouteID: "asxs",
PublicModel: "gpt-5.4",
ShadowModel: "gpt-5.4-asxs",
Status: "active",
}); err != nil {
t.Fatalf("CreateLogicalGroupRouteModel(asxs) error = %v", err)
}
if _, err := actions.CreateLogicalGroupRoute(ctx, CreateLogicalGroupRouteRequest{
LogicalGroupID: "gpt-shared",
RouteID: "codex2api",
Name: "Codex2API",
Status: "active",
Priority: 10,
ShadowGroupID: "gpt-shared__codex2api",
ShadowHostID: "remote43",
UpstreamBaseURLHint: "https://www.codex2api.com/v1",
}); err != nil {
t.Fatalf("CreateLogicalGroupRoute(codex2api) error = %v", err)
}
if _, err := actions.CreateLogicalGroupRouteModel(ctx, CreateLogicalGroupRouteModelRequest{
LogicalGroupID: "gpt-shared",
RouteID: "codex2api",
PublicModel: "gpt-5.4",
ShadowModel: "gpt-5.4",
Status: "active",
}); err != nil {
t.Fatalf("CreateLogicalGroupRouteModel(codex2api) error = %v", err)
}
first, err := actions.ResolveRoute(ctx, ResolveRouteRequest{
RequestID: "req-resolve-1",
LogicalGroupID: "gpt-shared",
PublicModel: "gpt-5.4",
Scope: "conversation",
SubjectID: "conv-1",
Sync: true,
})
if err != nil {
t.Fatalf("ResolveRoute(first) error = %v", err)
}
if first.RouteID != "codex2api" || first.StickyHit || first.StickyAction != "bind" {
t.Fatalf("ResolveRoute(first) = %+v, want codex2api bind miss", first)
}
second, err := actions.ResolveRoute(ctx, ResolveRouteRequest{
RequestID: "req-resolve-2",
LogicalGroupID: "gpt-shared",
PublicModel: "gpt-5.4",
Scope: "conversation",
SubjectID: "conv-1",
Sync: true,
})
if err != nil {
t.Fatalf("ResolveRoute(second) error = %v", err)
}
if second.RouteID != "codex2api" || !second.StickyHit || second.StickyAction != "hit" {
t.Fatalf("ResolveRoute(second) = %+v, want codex2api sticky hit", second)
}
sticky, err := actions.GetStickyBinding(ctx, GetStickyBindingRequest{
Scope: "conversation",
LogicalGroupID: "gpt-shared",
PublicModel: "gpt-5.4",
SubjectID: "conv-1",
})
if err != nil {
t.Fatalf("GetStickyBinding() error = %v", err)
}
if sticky.RouteID != "codex2api" {
t.Fatalf("GetStickyBinding() = %+v, want route codex2api", sticky)
}
decisions, err := actions.ListRouteDecisionLogs(ctx, ListRouteDecisionLogsRequest{
LogicalGroupID: "gpt-shared",
StickyKey: first.StickyKey,
Limit: 10,
})
if err != nil {
t.Fatalf("ListRouteDecisionLogs() error = %v", err)
}
if len(decisions) != 2 {
t.Fatalf("ListRouteDecisionLogs() len = %d, want 2", len(decisions))
}
if !decisions[0].StickyHit || decisions[1].StickyHit {
t.Fatalf("ListRouteDecisionLogs() = %+v, want latest hit then miss", decisions)
}
stickyAudits, err := actions.ListRouteStickyAudit(ctx, ListRouteStickyAuditRequest{
StickyKey: first.StickyKey,
Limit: 10,
})
if err != nil {
t.Fatalf("ListRouteStickyAudit() error = %v", err)
}
if len(stickyAudits) != 2 {
t.Fatalf("ListRouteStickyAudit() len = %d, want 2", len(stickyAudits))
}
if stickyAudits[0].Action != "hit" || stickyAudits[1].Action != "bind" {
t.Fatalf("ListRouteStickyAudit() = %+v, want latest hit then bind", stickyAudits)
}
}