Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions crates/aisix-core/src/models/routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ pub struct Routing {
/// Whether upstream 429 participates in retries and failover.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_on_429: Option<bool>,
/// Additional upstream HTTP status codes that participate in retries and failover. By default a non-429 4xx response is treated as a caller error and returned as-is; providers that use 4xx codes for transient conditions (model overload, queue full, quota exhaustion) can be listed here, for example `[408, 409]`. 5xx codes are already retryable, so listing them changes nothing. Authentication (`401`/`403`) and validation (`400`) codes should only be listed when the provider is known to use them for transient failures.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(inner(range(min = 400, max = 599)))]
pub fallback_on_statuses: Option<Vec<u16>>,
/// Policy to apply when every target is unavailable because of runtime health or cooldown state.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub when_all_unavailable: Option<WhenAllUnavailablePolicy>,
Expand Down Expand Up @@ -205,6 +209,12 @@ impl Routing {
self.retry_on_429.unwrap_or(false)
}

/// Configured status codes that opt into retry/failover; empty when
/// unset (the default behavior).
pub fn fallback_on_statuses_or_default(&self) -> &[u16] {
self.fallback_on_statuses.as_deref().unwrap_or(&[])
}

pub fn when_all_unavailable_or_default(&self) -> WhenAllUnavailablePolicy {
self.when_all_unavailable.unwrap_or_default()
}
Expand Down Expand Up @@ -258,6 +268,7 @@ mod tests {
retries: Some(0),
max_fallbacks: Some(0),
retry_on_429: None,
fallback_on_statuses: None,
when_all_unavailable: None,
sticky: None,
};
Expand All @@ -272,6 +283,7 @@ mod tests {
retries: None,
max_fallbacks: Some(99),
retry_on_429: None,
fallback_on_statuses: None,
when_all_unavailable: None,
sticky: None,
};
Expand Down
23 changes: 23 additions & 0 deletions crates/aisix-core/src/models/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1433,6 +1433,29 @@ mod tests {
assert!(validate_model(&v).is_err());
}

#[test]
fn routing_fallback_on_statuses_range_is_enforced() {
// AISIX-Cloud#1012: entries outside 400-599 are rejected by the
// same committed-schema validation the admin API and etcd watch
// paths share; an in-range list passes.
let bad = json!({
"display_name": "router-fos",
"routing": {
"targets": [{"model": "x"}],
"fallback_on_statuses": [300]
}
});
assert!(validate_model(&bad).is_err());
let good = json!({
"display_name": "router-fos",
"routing": {
"targets": [{"model": "x"}],
"fallback_on_statuses": [408, 422]
}
});
validate_model(&good).unwrap();
}

#[test]
fn cooldown_rejects_invalid_status_code() {
let v = json!({
Expand Down
18 changes: 15 additions & 3 deletions crates/aisix-proxy/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,12 @@ async fn dispatch(
.as_ref()
.map(|r| r.retry_on_429_or_default())
.unwrap_or(false);
let fallback_statuses: &[u16] = virtual_entry
.value
.routing
.as_ref()
.map(|r| r.fallback_on_statuses_or_default())
.unwrap_or(&[]);
let is_routing_request =
virtual_entry.value.routing.is_some() || virtual_entry.value.is_semantic();
let mut stream_routing = RoutingTelemetry::default();
Expand Down Expand Up @@ -1241,7 +1247,7 @@ async fn dispatch(
error_message: attempt_error_message(&err),
latency_ms,
});
let retryable = is_retryable(&err, retry_on_429);
let retryable = is_retryable(&err, retry_on_429, fallback_statuses);
tracing::warn!(
target_model = %model.display_name,
error = %err,
Expand Down Expand Up @@ -1819,6 +1825,12 @@ async fn dispatch(
.as_ref()
.map(|routing| routing.retry_on_429_or_default())
.unwrap_or(false);
let fallback_statuses: &[u16] = virtual_entry
.value
.routing
.as_ref()
.map(|routing| routing.fallback_on_statuses_or_default())
.unwrap_or(&[]);
let is_routing_request =
virtual_entry.value.routing.is_some() || virtual_entry.value.is_semantic();
let mut routing = RoutingTelemetry::default();
Expand Down Expand Up @@ -1924,7 +1936,7 @@ async fn dispatch(
error_message: attempt_error_message(&err),
latency_ms: attempt_latency_ms,
});
let retryable = is_retryable(&err, retry_on_429);
let retryable = is_retryable(&err, retry_on_429, fallback_statuses);
tracing::warn!(
target_model = %model.display_name,
target_attempt = attempt_idx + 1,
Expand Down Expand Up @@ -1971,7 +1983,7 @@ async fn dispatch(
break;
}
if let Some(err) = last_err.as_ref() {
if !is_retryable(err, retry_on_429) {
if !is_retryable(err, retry_on_429, fallback_statuses) {
break;
}
}
Expand Down
8 changes: 7 additions & 1 deletion crates/aisix-proxy/src/count_tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ async fn dispatch(
.as_ref()
.map(|r| r.retry_on_429_or_default())
.unwrap_or(false);
let fallback_statuses: &[u16] = model_entry
.value
.routing
.as_ref()
.map(|r| r.fallback_on_statuses_or_default())
.unwrap_or(&[]);

let mut last_err: Option<ProxyError> = None;
let mut any_anthropic = false;
Expand All @@ -218,7 +224,7 @@ async fn dispatch(
Err(e) => {
let retryable = matches!(
&e,
ProxyError::Bridge(be) if crate::routing::is_retryable(be, retry_on_429)
ProxyError::Bridge(be) if crate::routing::is_retryable(be, retry_on_429, fallback_statuses)
);
last_err = Some(e);
if !retryable {
Expand Down
8 changes: 7 additions & 1 deletion crates/aisix-proxy/src/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,12 @@ async fn dispatch(
.as_ref()
.map(|r| r.retry_on_429_or_default())
.unwrap_or(false);
let fallback_statuses: &[u16] = model_entry
.value
.routing
.as_ref()
.map(|r| r.fallback_on_statuses_or_default())
.unwrap_or(&[]);
// Routing target names only matter on the telemetry for a real Model
// Group; a direct model leaves `attempt_model` empty (its `model_id`
// already identifies it), matching chat.rs.
Expand Down Expand Up @@ -734,7 +740,7 @@ async fn dispatch(
Err(e) => {
let retryable = matches!(
&e,
ProxyError::Bridge(be) if crate::routing::is_retryable(be, retry_on_429)
ProxyError::Bridge(be) if crate::routing::is_retryable(be, retry_on_429, fallback_statuses)
);
let (error_class, error_message) = attempt_error_from_proxy(&e);
routing.attempts.push(AttemptRecord {
Expand Down
8 changes: 7 additions & 1 deletion crates/aisix-proxy/src/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,12 @@ async fn dispatch(
.as_ref()
.map(|r| r.retry_on_429_or_default())
.unwrap_or(false);
let fallback_statuses: &[u16] = model_entry
.value
.routing
.as_ref()
.map(|r| r.fallback_on_statuses_or_default())
.unwrap_or(&[]);
let is_routing_request = model_entry.value.routing.is_some();
let mut routing = RoutingTelemetry::default();
// `routing.retries` — same-target retries (with backoff) before failing
Expand Down Expand Up @@ -626,7 +632,7 @@ async fn dispatch(
Err(e) => {
let retryable = matches!(
&e,
ProxyError::Bridge(be) if crate::routing::is_retryable(be, retry_on_429)
ProxyError::Bridge(be) if crate::routing::is_retryable(be, retry_on_429, fallback_statuses)
);
let (error_class, error_message) = attempt_error_from_proxy(&e);
routing.attempts.push(AttemptRecord {
Expand Down
98 changes: 87 additions & 11 deletions crates/aisix-proxy/src/routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,18 @@ const FALLBACK_ALL_UNHEALTHY_RETRY_AFTER: Duration = Duration::from_secs(30);
/// as retryable. Non-429 4xx is the caller's mistake — retrying won't
/// help and may amplify damage. Everything else (5xx, timeout,
/// transport, decode, config, stream abort) gets the retry/failover path.
pub fn is_retryable(err: &BridgeError, retry_on_429: bool) -> bool {
///
/// `fallback_on_statuses` (AISIX-Cloud#1012) is the routing model's
/// explicit opt-in list for providers that use 4xx codes for transient
/// conditions (overload, queue full, quota): a status in the list is
/// retryable regardless of the default classification. Empty by default,
/// which preserves the historical behavior exactly.
pub fn is_retryable(err: &BridgeError, retry_on_429: bool, fallback_on_statuses: &[u16]) -> bool {
match err {
BridgeError::UpstreamStatus { status, .. } => {
if fallback_on_statuses.contains(status) {
return true;
}
if *status == 429 {
return retry_on_429;
}
Expand Down Expand Up @@ -524,6 +533,7 @@ mod tests {
retries: None,
max_fallbacks,
retry_on_429: None,
fallback_on_statuses: None,
when_all_unavailable: None,
sticky: None,
}
Expand Down Expand Up @@ -917,32 +927,98 @@ mod tests {
fn is_retryable_distinguishes_4xx_from_other_failures() {
assert!(!is_retryable(
&BridgeError::upstream_status(400, "bad request"),
false
false,
&[]
));
assert!(!is_retryable(
&BridgeError::upstream_status(429, "rate limited"),
false
false,
&[]
));
assert!(is_retryable(
&BridgeError::upstream_status(429, "rate limited"),
true
true,
&[]
));
assert!(is_retryable(
&BridgeError::upstream_status(502, "bad gateway"),
false
false,
&[]
));
assert!(is_retryable(
&BridgeError::Timeout { elapsed_ms: 1 },
false,
&[]
));
assert!(is_retryable(
&BridgeError::Transport("conn".into()),
false,
&[]
));
assert!(is_retryable(&BridgeError::Timeout { elapsed_ms: 1 }, false));
assert!(is_retryable(&BridgeError::Transport("conn".into()), false));
assert!(is_retryable(
&BridgeError::UpstreamDecode("x".into()),
false
false,
&[]
));
assert!(is_retryable(
&BridgeError::Config("bad key".into()),
false,
&[]
));
assert!(is_retryable(&BridgeError::Config("bad key".into()), false));
assert!(is_retryable(&BridgeError::StreamAborted, false));
assert!(is_retryable(&BridgeError::StreamAborted, false, &[]));
// #367: customer-fixable config is a 4xx — not retryable.
assert!(!is_retryable(
&BridgeError::InvalidUpstreamConfig("no api_base".into()),
false
false,
&[]
));
}

/// AISIX-Cloud#1012: `fallback_on_statuses` opts specific upstream
/// status codes into retry/failover. The list is additive — codes not
/// listed keep the default classification — and it never resurrects
/// non-status failures (customer-fixable config stays terminal).
#[test]
fn fallback_on_statuses_opts_specific_codes_into_retry() {
// A listed 4xx becomes retryable.
assert!(is_retryable(
&BridgeError::upstream_status(408, "request timeout"),
false,
&[408, 409]
));
assert!(is_retryable(
&BridgeError::upstream_status(409, "conflict"),
false,
&[408, 409]
));
// Codes NOT in the list keep the default: terminal.
assert!(!is_retryable(
&BridgeError::upstream_status(422, "unprocessable"),
false,
&[408, 409]
));
assert!(!is_retryable(
&BridgeError::upstream_status(400, "bad request"),
false,
&[408, 409]
));
// 429 in the list works without retry_on_429.
assert!(is_retryable(
&BridgeError::upstream_status(429, "rate limited"),
false,
&[429]
));
// 5xx stays retryable whether or not listed.
assert!(is_retryable(
&BridgeError::upstream_status(503, "unavailable"),
false,
&[408]
));
// The list is status-scoped: it never affects non-status errors.
assert!(!is_retryable(
&BridgeError::InvalidUpstreamConfig("no api_base".into()),
false,
&[400, 401, 403]
));
}

Expand Down
10 changes: 10 additions & 0 deletions schemas/resources/model.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,16 @@
"Routing": {
"additionalProperties": false,
"properties": {
"fallback_on_statuses": {
"description": "Additional upstream HTTP status codes that participate in retries and failover. By default a non-429 4xx response is treated as a caller error and returned as-is; providers that use 4xx codes for transient conditions (model overload, queue full, quota exhaustion) can be listed here, for example `[408, 409]`. 5xx codes are already retryable, so listing them changes nothing. Authentication (`401`/`403`) and validation (`400`) codes should only be listed when the provider is known to use them for transient failures.",
"items": {
"format": "uint16",
"maximum": 599.0,
"minimum": 400.0,
"type": "integer"
},
"type": "array"
},
"max_fallbacks": {
"description": "Max number of later targets to attempt after the initial target fails permanently. When omitted, all later targets may be attempted.",
"format": "uint32",
Expand Down
13 changes: 13 additions & 0 deletions schemas/resources/routing.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@
"targets"
],
"properties": {
"fallback_on_statuses": {
"description": "Additional upstream HTTP status codes that participate in retries and failover. By default a non-429 4xx response is treated as a caller error and returned as-is; providers that use 4xx codes for transient conditions (model overload, queue full, quota exhaustion) can be listed here, for example `[408, 409]`. 5xx codes are already retryable, so listing them changes nothing. Authentication (`401`/`403`) and validation (`400`) codes should only be listed when the provider is known to use them for transient failures.",
"type": [
"array",
"null"
],
"items": {
"type": "integer",
"format": "uint16",
"maximum": 599.0,
"minimum": 400.0
}
},
"max_fallbacks": {
"description": "Max number of later targets to attempt after the initial target fails permanently. When omitted, all later targets may be attempted.",
"type": [
Expand Down
Loading
Loading