feat: improve failover rules and request timeline

This commit is contained in:
elky
2026-06-11 00:49:29 +08:00
parent 31fade82f6
commit 30b545785f
15 changed files with 1576 additions and 199 deletions
@@ -61,11 +61,8 @@ pub(crate) fn classify_local_failover(
}
if input.status_code >= 400
&& input.response_text.is_some_and(|text| {
policy
.error_stop_patterns
.iter()
.any(|rule| local_failover_regex_rule_matches(rule, text, input.status_code))
&& policy.error_stop_patterns.iter().any(|rule| {
local_failover_regex_rule_matches(rule, input.response_text, input.status_code)
})
{
return LocalFailoverClassification::StopErrorPattern;
@@ -76,7 +73,7 @@ pub(crate) fn classify_local_failover(
policy
.success_failover_patterns
.iter()
.any(|rule| local_failover_regex_rule_matches(rule, text, input.status_code))
.any(|rule| local_failover_regex_rule_matches(rule, Some(text), input.status_code))
})
{
return LocalFailoverClassification::RetrySuccessPattern;
@@ -190,14 +187,23 @@ fn first_non_empty_json_text(
fn local_failover_regex_rule_matches(
rule: &LocalFailoverRegexRule,
response_text: &str,
response_text: Option<&str>,
status_code: u16,
) -> bool {
if !rule.status_codes.is_empty() && !rule.status_codes.contains(&status_code) {
return false;
}
Regex::new(&rule.pattern)
let pattern = rule.pattern.trim();
if pattern.is_empty() {
return !rule.status_codes.is_empty();
}
let Some(response_text) = response_text else {
return false;
};
Regex::new(pattern)
.ok()
.is_some_and(|regex| regex.is_match(response_text))
}
@@ -260,6 +266,50 @@ mod tests {
);
}
#[test]
fn classifier_detects_error_stop_pattern_without_status_codes_on_any_error_status() {
let policy = LocalFailoverPolicy {
error_stop_patterns: vec![LocalFailoverRegexRule {
pattern: "content_policy_violation".to_string(),
status_codes: BTreeSet::new(),
}],
..LocalFailoverPolicy::default()
};
for status_code in [400, 429, 503] {
assert_eq!(
classify_local_failover(
&policy,
LocalFailoverInput::new(
status_code,
Some("{\"error\":\"content_policy_violation\"}")
)
),
LocalFailoverClassification::StopErrorPattern
);
}
}
#[test]
fn classifier_detects_status_only_error_stop_rule_without_response_text() {
let policy = LocalFailoverPolicy {
error_stop_patterns: vec![LocalFailoverRegexRule {
pattern: String::new(),
status_codes: [429].into_iter().collect(),
}],
..LocalFailoverPolicy::default()
};
assert_eq!(
classify_local_failover(&policy, LocalFailoverInput::new(429, None)),
LocalFailoverClassification::StopErrorPattern
);
assert_eq!(
classify_local_failover(&policy, LocalFailoverInput::new(503, None)),
LocalFailoverClassification::RetryUpstreamFailure
);
}
#[test]
fn classifier_detects_success_continue_status_code() {
let policy = LocalFailoverPolicy {
+19 -30
View File
@@ -25,27 +25,8 @@ pub(crate) struct LocalFailoverRegexRule {
pub(crate) async fn resolve_local_failover_policy(
state: &AppState,
plan: &ExecutionPlan,
report_context: Option<&serde_json::Value>,
_report_context: Option<&serde_json::Value>,
) -> LocalFailoverPolicy {
if let Some(policy) = local_failover_policy_from_report_context(report_context) {
debug!(
event_name = "local_failover_policy_loaded",
log_type = "debug",
request_id = %plan.request_id,
provider_id = %plan.provider_id,
endpoint_id = %plan.endpoint_id,
key_id = %plan.key_id,
source = "report_context",
max_retries = ?policy.max_retries,
stop_status_code_count = policy.stop_status_codes.len(),
continue_status_code_count = policy.continue_status_codes.len(),
success_failover_pattern_count = policy.success_failover_patterns.len(),
error_stop_pattern_count = policy.error_stop_patterns.len(),
"gateway loaded local failover policy from report context"
);
return policy;
}
let transport = match state
.read_provider_transport_snapshot(&plan.provider_id, &plan.endpoint_id, &plan.key_id)
.await
@@ -201,31 +182,39 @@ fn parse_regex_rules(
rules: &serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Vec<LocalFailoverRegexRule> {
let allow_status_only = key == "error_stop_patterns";
rules
.get(key)
.and_then(Value::as_array)
.into_iter()
.flat_map(|items| items.iter())
.filter_map(parse_regex_rule)
.filter_map(|value| parse_regex_rule(value, allow_status_only))
.collect()
}
fn parse_regex_rule(value: &serde_json::Value) -> Option<LocalFailoverRegexRule> {
fn parse_regex_rule(
value: &serde_json::Value,
allow_status_only: bool,
) -> Option<LocalFailoverRegexRule> {
let object = value.as_object()?;
let pattern = object
.get("pattern")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
.unwrap_or_default();
let status_codes: BTreeSet<u16> = object
.get("status_codes")
.and_then(Value::as_array)
.into_iter()
.flat_map(|values| values.iter())
.filter_map(|value| parse_u64_value(value).and_then(|value| u16::try_from(value).ok()))
.collect();
if pattern.is_empty() && (!allow_status_only || status_codes.is_empty()) {
return None;
}
Some(LocalFailoverRegexRule {
pattern: pattern.to_string(),
status_codes: object
.get("status_codes")
.and_then(Value::as_array)
.into_iter()
.flat_map(|values| values.iter())
.filter_map(|value| parse_u64_value(value).and_then(|value| u16::try_from(value).ok()))
.collect(),
status_codes,
})
}