mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 17:37:47 +08:00
feat(routing): move sticky-key retries into routing policy with lazy attempts
Replace the provider/endpoint max_retries fields as the source of same-key retries with a routing policy setting, sticky_key_attempts (default 2). Only the first-ranked candidate is retried on the same key; every failover candidate gets a single attempt so failover keeps advancing instead of retrying each fallback key. Materialize exactly one attempt per candidate and derive same-key retries in the attempt loop after a candidate-scoped failure, so the retry budget no longer inflates up-front materialization and needs no upper bound. The budget travels in the report context; retries reuse the plan with a fresh candidate id and incremented retry index. Pool groups only retry their first key within the retry-index stride. Expose the setting in the routing profile editor and the set_scheduling rule action, and drop the max_retries input from the provider form.
This commit is contained in:
@@ -73,6 +73,8 @@ pub enum RoutingAction {
|
||||
priority_mode: Option<RoutingSetPriorityMode>,
|
||||
scheduling_mode: Option<RoutingSchedulingMode>,
|
||||
keep_priority_on_conversion: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
sticky_key_attempts: Option<u32>,
|
||||
},
|
||||
SetProviderPriority {
|
||||
provider_id: String,
|
||||
|
||||
@@ -15,7 +15,7 @@ pub use conditions::{RoutingCondition, RoutingConditionContext, RoutingCondition
|
||||
pub use model::{
|
||||
RoutingDefaultPolicy, RoutingGroupBinding, RoutingGroupBindingSubject, RoutingGroupConfig,
|
||||
RoutingGroupRecord, RoutingGroupVersionRecord, RoutingModelPolicy, RoutingPoolPolicyOverride,
|
||||
RoutingRule, RoutingSchedulingPreset,
|
||||
RoutingRule, RoutingSchedulingPreset, DEFAULT_STICKY_KEY_ATTEMPTS,
|
||||
};
|
||||
pub use mutations::{
|
||||
apply_json_patch_operations, validate_header_patch, validate_json_patch_operations,
|
||||
|
||||
@@ -23,7 +23,11 @@ pub struct RoutingPoolPolicyOverride {
|
||||
pub scheduling_presets: Vec<RoutingSchedulingPreset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
/// Default number of attempts on the first-ranked (sticky) candidate before
|
||||
/// failing over: one retry on the same key.
|
||||
pub const DEFAULT_STICKY_KEY_ATTEMPTS: u32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RoutingDefaultPolicy {
|
||||
#[serde(default)]
|
||||
pub priority_mode: RoutingSetPriorityMode,
|
||||
@@ -31,6 +35,26 @@ pub struct RoutingDefaultPolicy {
|
||||
pub scheduling_mode: RoutingSchedulingMode,
|
||||
#[serde(default)]
|
||||
pub keep_priority_on_conversion: bool,
|
||||
/// Total attempts on the first-ranked candidate before moving on. Later
|
||||
/// candidates always get a single attempt so failover keeps advancing.
|
||||
/// `0` and `1` both mean no same-key retry.
|
||||
#[serde(default = "default_sticky_key_attempts")]
|
||||
pub sticky_key_attempts: u32,
|
||||
}
|
||||
|
||||
impl Default for RoutingDefaultPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
priority_mode: RoutingSetPriorityMode::default(),
|
||||
scheduling_mode: RoutingSchedulingMode::default(),
|
||||
keep_priority_on_conversion: false,
|
||||
sticky_key_attempts: DEFAULT_STICKY_KEY_ATTEMPTS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_sticky_key_attempts() -> u32 {
|
||||
DEFAULT_STICKY_KEY_ATTEMPTS
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
|
||||
@@ -57,6 +57,9 @@ pub struct ResolvedRoutingPolicy {
|
||||
pub priority_mode: RoutingSetPriorityMode,
|
||||
pub scheduling_mode: RoutingSchedulingMode,
|
||||
pub keep_priority_on_conversion: bool,
|
||||
/// See `RoutingDefaultPolicy::sticky_key_attempts`.
|
||||
#[serde(default = "default_sticky_key_attempts")]
|
||||
pub sticky_key_attempts: u32,
|
||||
pub ranking_overlay: RankingOverlay,
|
||||
pub mutation_plan: MutationPlan,
|
||||
#[serde(default)]
|
||||
@@ -89,6 +92,7 @@ pub fn resolve_routing_policy(
|
||||
priority_mode: config.default_policy.priority_mode,
|
||||
scheduling_mode: config.default_policy.scheduling_mode,
|
||||
keep_priority_on_conversion: config.default_policy.keep_priority_on_conversion,
|
||||
sticky_key_attempts: config.default_policy.sticky_key_attempts,
|
||||
ranking_overlay: RankingOverlay::default(),
|
||||
mutation_plan: MutationPlan::default(),
|
||||
pool_policy_overrides: BTreeMap::new(),
|
||||
@@ -205,6 +209,7 @@ fn apply_action(
|
||||
priority_mode,
|
||||
scheduling_mode,
|
||||
keep_priority_on_conversion,
|
||||
sticky_key_attempts,
|
||||
} => {
|
||||
if let Some(priority_mode) = priority_mode {
|
||||
policy.priority_mode = *priority_mode;
|
||||
@@ -215,6 +220,9 @@ fn apply_action(
|
||||
if let Some(keep_priority_on_conversion) = keep_priority_on_conversion {
|
||||
policy.keep_priority_on_conversion = *keep_priority_on_conversion;
|
||||
}
|
||||
if let Some(sticky_key_attempts) = sticky_key_attempts {
|
||||
policy.sticky_key_attempts = *sticky_key_attempts;
|
||||
}
|
||||
}
|
||||
RoutingAction::SetProviderPriority {
|
||||
provider_id,
|
||||
@@ -282,6 +290,10 @@ fn model_allowed(patterns: &[String], requested_model: &str) -> bool {
|
||||
.any(|pattern| model_pattern_matches(pattern, requested_model))
|
||||
}
|
||||
|
||||
fn default_sticky_key_attempts() -> u32 {
|
||||
crate::model::DEFAULT_STICKY_KEY_ATTEMPTS
|
||||
}
|
||||
|
||||
fn model_pattern_matches(pattern: &str, value: &str) -> bool {
|
||||
let pattern = pattern.trim();
|
||||
if pattern == "*" {
|
||||
@@ -384,6 +396,7 @@ mod tests {
|
||||
priority_mode: RoutingSetPriorityMode::GlobalKey,
|
||||
scheduling_mode: RoutingSchedulingMode::LoadBalance,
|
||||
keep_priority_on_conversion: true,
|
||||
sticky_key_attempts: 3,
|
||||
},
|
||||
model_policies: vec![RoutingModelPolicy {
|
||||
model: "special-model".to_string(),
|
||||
@@ -415,6 +428,7 @@ mod tests {
|
||||
assert_eq!(special.priority_mode, RoutingSetPriorityMode::GlobalKey);
|
||||
assert_eq!(special.scheduling_mode, RoutingSchedulingMode::LoadBalance);
|
||||
assert!(special.keep_priority_on_conversion);
|
||||
assert_eq!(special.sticky_key_attempts, 3);
|
||||
assert_eq!(
|
||||
special.ranking_overlay.allowed_providers,
|
||||
vec!["provider-special"]
|
||||
@@ -448,6 +462,7 @@ mod tests {
|
||||
assert_eq!(ordinary.priority_mode, RoutingSetPriorityMode::GlobalKey);
|
||||
assert_eq!(ordinary.scheduling_mode, RoutingSchedulingMode::LoadBalance);
|
||||
assert!(ordinary.keep_priority_on_conversion);
|
||||
assert_eq!(ordinary.sticky_key_attempts, 3);
|
||||
assert!(ordinary.ranking_overlay.allowed_providers.is_empty());
|
||||
assert!(ordinary.ranking_overlay.allowed_keys.is_empty());
|
||||
assert!(ordinary
|
||||
@@ -456,6 +471,76 @@ mod tests {
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sticky_key_attempts_defaults_to_two_and_can_be_overridden_by_rule() {
|
||||
let default_config = RoutingGroupConfig::default();
|
||||
let default_policy = resolve_routing_policy(
|
||||
&default_config,
|
||||
RoutingPolicyInput {
|
||||
group_id: None,
|
||||
group_version: None,
|
||||
selection_source: "test",
|
||||
requested_model: "gpt-5",
|
||||
resolved_model: "gpt-5",
|
||||
api_format: "openai:chat",
|
||||
user_id: None,
|
||||
api_key_id: None,
|
||||
headers: &json!({}),
|
||||
body: &json!({}),
|
||||
phase: RoutingRulePhase::ClientRequest,
|
||||
},
|
||||
)
|
||||
.expect("default config should resolve");
|
||||
assert_eq!(
|
||||
default_policy.sticky_key_attempts,
|
||||
crate::DEFAULT_STICKY_KEY_ATTEMPTS
|
||||
);
|
||||
|
||||
let parsed: RoutingGroupConfig =
|
||||
serde_json::from_value(json!({ "default_policy": { "priority_mode": "provider" } }))
|
||||
.expect("legacy config without sticky_key_attempts should deserialize");
|
||||
assert_eq!(
|
||||
parsed.default_policy.sticky_key_attempts,
|
||||
crate::DEFAULT_STICKY_KEY_ATTEMPTS
|
||||
);
|
||||
|
||||
let config = RoutingGroupConfig {
|
||||
rules: vec![RoutingRule {
|
||||
id: "no-sticky-retry".to_string(),
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
phase: RoutingRulePhase::ClientRequest,
|
||||
conditions: RoutingCondition::default(),
|
||||
actions: vec![RoutingAction::SetScheduling {
|
||||
priority_mode: None,
|
||||
scheduling_mode: None,
|
||||
keep_priority_on_conversion: None,
|
||||
sticky_key_attempts: Some(1),
|
||||
}],
|
||||
stop_processing: false,
|
||||
}],
|
||||
..RoutingGroupConfig::default()
|
||||
};
|
||||
let policy = resolve_routing_policy(
|
||||
&config,
|
||||
RoutingPolicyInput {
|
||||
group_id: None,
|
||||
group_version: None,
|
||||
selection_source: "test",
|
||||
requested_model: "gpt-5",
|
||||
resolved_model: "gpt-5",
|
||||
api_format: "openai:chat",
|
||||
user_id: None,
|
||||
api_key_id: None,
|
||||
headers: &json!({}),
|
||||
body: &json!({}),
|
||||
phase: RoutingRulePhase::ClientRequest,
|
||||
},
|
||||
)
|
||||
.expect("rule config should resolve");
|
||||
assert_eq!(policy.sticky_key_attempts, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_disallowed_model() {
|
||||
let config = RoutingGroupConfig {
|
||||
|
||||
@@ -271,6 +271,7 @@ mod tests {
|
||||
priority_mode: None,
|
||||
scheduling_mode: None,
|
||||
keep_priority_on_conversion: Some(true),
|
||||
sticky_key_attempts: None,
|
||||
},
|
||||
"set_scheduling",
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user