200 lines
6.2 KiB
Go
200 lines
6.2 KiB
Go
package routing
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
RuntimeBackendMemory = "memory"
|
|
RuntimeBackendRedis = "redis"
|
|
|
|
StickyScopeConversation = "conversation"
|
|
StickyScopeSession = "session"
|
|
StickyScopeUser = "user"
|
|
)
|
|
|
|
type StickyBinding struct {
|
|
LogicalGroupID string `json:"logical_group_id"`
|
|
PublicModel string `json:"public_model"`
|
|
RouteID string `json:"route_id"`
|
|
ShadowGroupID string `json:"shadow_group_id"`
|
|
BoundAt string `json:"bound_at,omitempty"`
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
|
}
|
|
|
|
type RouteFailureState struct {
|
|
RouteID string `json:"route_id"`
|
|
FailureCount int `json:"failure_count"`
|
|
LastErrorClass string `json:"last_error_class,omitempty"`
|
|
LastFailureAt string `json:"last_failure_at,omitempty"`
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
|
}
|
|
|
|
type RouteCooldownState struct {
|
|
RouteID string `json:"route_id"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Until string `json:"until,omitempty"`
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
|
}
|
|
|
|
type StickyStore interface {
|
|
Get(ctx context.Context, key string) (StickyBinding, bool, error)
|
|
Set(ctx context.Context, key string, binding StickyBinding, ttl time.Duration) error
|
|
Delete(ctx context.Context, key string) error
|
|
|
|
GetRouteFailure(ctx context.Context, routeID string) (RouteFailureState, bool, error)
|
|
SetRouteFailure(ctx context.Context, routeID string, state RouteFailureState, ttl time.Duration) error
|
|
ClearRouteFailure(ctx context.Context, routeID string) error
|
|
|
|
GetCooldown(ctx context.Context, routeID string) (RouteCooldownState, bool, error)
|
|
SetCooldown(ctx context.Context, routeID string, state RouteCooldownState, ttl time.Duration) error
|
|
ClearCooldown(ctx context.Context, routeID string) error
|
|
}
|
|
|
|
type StoreConfig struct {
|
|
Backend string
|
|
Redis RedisConfig
|
|
}
|
|
|
|
func NormalizeRuntimeBackend(backend string) (string, error) {
|
|
switch strings.ToLower(strings.TrimSpace(backend)) {
|
|
case "", RuntimeBackendMemory:
|
|
return RuntimeBackendMemory, nil
|
|
case RuntimeBackendRedis:
|
|
return RuntimeBackendRedis, nil
|
|
default:
|
|
return "", fmt.Errorf("unsupported route runtime backend %q", backend)
|
|
}
|
|
}
|
|
|
|
func BuildStickyKey(scope, logicalGroupID, publicModel, subjectID string) (string, error) {
|
|
scope = strings.ToLower(strings.TrimSpace(scope))
|
|
logicalGroupID = strings.TrimSpace(logicalGroupID)
|
|
publicModel = strings.TrimSpace(publicModel)
|
|
subjectID = strings.TrimSpace(subjectID)
|
|
|
|
switch {
|
|
case logicalGroupID == "":
|
|
return "", fmt.Errorf("logical_group_id is required")
|
|
case publicModel == "":
|
|
return "", fmt.Errorf("public_model is required")
|
|
case subjectID == "":
|
|
return "", fmt.Errorf("subject_id is required")
|
|
}
|
|
|
|
var subjectPrefix string
|
|
switch scope {
|
|
case StickyScopeConversation:
|
|
subjectPrefix = "conv"
|
|
case StickyScopeSession:
|
|
subjectPrefix = "sess"
|
|
case StickyScopeUser:
|
|
subjectPrefix = "user"
|
|
default:
|
|
return "", fmt.Errorf("unsupported sticky scope %q", scope)
|
|
}
|
|
|
|
return fmt.Sprintf("lg:%s:m:%s:%s:%s", logicalGroupID, publicModel, subjectPrefix, subjectID), nil
|
|
}
|
|
|
|
func BuildRouteFailureKey(routeID string) (string, error) {
|
|
routeID = strings.TrimSpace(routeID)
|
|
if routeID == "" {
|
|
return "", fmt.Errorf("route_id is required")
|
|
}
|
|
return "routefail:" + routeID, nil
|
|
}
|
|
|
|
func BuildRouteCooldownKey(routeID string) (string, error) {
|
|
routeID = strings.TrimSpace(routeID)
|
|
if routeID == "" {
|
|
return "", fmt.Errorf("route_id is required")
|
|
}
|
|
return "routecool:" + routeID, nil
|
|
}
|
|
|
|
func normalizeStickyBinding(binding StickyBinding, ttl time.Duration, now time.Time) (StickyBinding, error) {
|
|
binding.LogicalGroupID = strings.TrimSpace(binding.LogicalGroupID)
|
|
binding.PublicModel = strings.TrimSpace(binding.PublicModel)
|
|
binding.RouteID = strings.TrimSpace(binding.RouteID)
|
|
binding.ShadowGroupID = strings.TrimSpace(binding.ShadowGroupID)
|
|
|
|
switch {
|
|
case binding.LogicalGroupID == "":
|
|
return StickyBinding{}, fmt.Errorf("logical_group_id is required")
|
|
case binding.PublicModel == "":
|
|
return StickyBinding{}, fmt.Errorf("public_model is required")
|
|
case binding.RouteID == "":
|
|
return StickyBinding{}, fmt.Errorf("route_id is required")
|
|
case binding.ShadowGroupID == "":
|
|
return StickyBinding{}, fmt.Errorf("shadow_group_id is required")
|
|
case ttl <= 0:
|
|
return StickyBinding{}, fmt.Errorf("ttl must be positive")
|
|
}
|
|
|
|
if binding.BoundAt == "" {
|
|
binding.BoundAt = now.UTC().Format(time.RFC3339)
|
|
}
|
|
if binding.ExpiresAt == "" {
|
|
binding.ExpiresAt = now.UTC().Add(ttl).Format(time.RFC3339)
|
|
}
|
|
return binding, nil
|
|
}
|
|
|
|
func normalizeRouteFailureState(routeID string, state RouteFailureState, ttl time.Duration, now time.Time) (RouteFailureState, error) {
|
|
routeID = strings.TrimSpace(routeID)
|
|
state.RouteID = strings.TrimSpace(state.RouteID)
|
|
state.LastErrorClass = strings.TrimSpace(state.LastErrorClass)
|
|
|
|
if state.RouteID == "" {
|
|
state.RouteID = routeID
|
|
}
|
|
switch {
|
|
case routeID == "":
|
|
return RouteFailureState{}, fmt.Errorf("route_id is required")
|
|
case state.RouteID != routeID:
|
|
return RouteFailureState{}, fmt.Errorf("route_id mismatch")
|
|
case state.FailureCount < 0:
|
|
return RouteFailureState{}, fmt.Errorf("failure_count must be >= 0")
|
|
case ttl <= 0:
|
|
return RouteFailureState{}, fmt.Errorf("ttl must be positive")
|
|
}
|
|
|
|
if state.LastFailureAt == "" {
|
|
state.LastFailureAt = now.UTC().Format(time.RFC3339)
|
|
}
|
|
if state.ExpiresAt == "" {
|
|
state.ExpiresAt = now.UTC().Add(ttl).Format(time.RFC3339)
|
|
}
|
|
return state, nil
|
|
}
|
|
|
|
func normalizeRouteCooldownState(routeID string, state RouteCooldownState, ttl time.Duration, now time.Time) (RouteCooldownState, error) {
|
|
routeID = strings.TrimSpace(routeID)
|
|
state.RouteID = strings.TrimSpace(state.RouteID)
|
|
state.Reason = strings.TrimSpace(state.Reason)
|
|
|
|
if state.RouteID == "" {
|
|
state.RouteID = routeID
|
|
}
|
|
switch {
|
|
case routeID == "":
|
|
return RouteCooldownState{}, fmt.Errorf("route_id is required")
|
|
case state.RouteID != routeID:
|
|
return RouteCooldownState{}, fmt.Errorf("route_id mismatch")
|
|
case ttl <= 0:
|
|
return RouteCooldownState{}, fmt.Errorf("ttl must be positive")
|
|
}
|
|
|
|
if state.Until == "" {
|
|
state.Until = now.UTC().Add(ttl).Format(time.RFC3339)
|
|
}
|
|
if state.ExpiresAt == "" {
|
|
state.ExpiresAt = now.UTC().Add(ttl).Format(time.RFC3339)
|
|
}
|
|
return state, nil
|
|
}
|