forked from boos/BoosAPI
refactor: purge Node.js, Rust is the one true backend
- Moved boosapi-rs/* to repo root - Deleted all Node.js source (src/server/, src/web/, scripts/) - Deleted node_modules, package.json, tsconfig, vite.config - Updated .gitignore and AGENTS.md for Rust-only stack - Fixed static file path for frontend dist/web/ - Dockerfile stays the same (already Rust-only) - Kept dist/web/ for Rust static serving
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
use super::retry_policy::classify_failure;
|
||||
use super::types::{
|
||||
AttemptFailure, AttemptFailureAction, AttemptResult, ExecuteAttemptContext, ExecuteInput,
|
||||
ExecuteResult, FailureInfo, FailureReason, SelectedChannel, SuccessMetrics,
|
||||
};
|
||||
use futures::future::BoxFuture;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
const MAX_ATTEMPTS: usize = 5;
|
||||
|
||||
pub struct ProxyConductor {
|
||||
pub select_channel: Arc<
|
||||
dyn Fn(String, Option<Value>) -> BoxFuture<'static, Option<SelectedChannel>> + Send + Sync,
|
||||
>,
|
||||
pub select_next_channel: Arc<
|
||||
dyn Fn(String, Vec<i64>, Option<Value>) -> BoxFuture<'static, Option<SelectedChannel>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>,
|
||||
pub record_success: Arc<dyn Fn(i64, SuccessMetrics) -> BoxFuture<'static, ()> + Send + Sync>,
|
||||
pub record_failure: Arc<dyn Fn(i64, FailureInfo) -> BoxFuture<'static, ()> + Send + Sync>,
|
||||
pub refresh_auth: Option<
|
||||
Arc<
|
||||
dyn Fn(SelectedChannel, FailureInfo) -> BoxFuture<'static, Option<SelectedChannel>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>,
|
||||
>,
|
||||
}
|
||||
|
||||
impl ProxyConductor {
|
||||
pub async fn execute(&self, input: ExecuteInput) -> ExecuteResult {
|
||||
let mut attempts = 0;
|
||||
let mut exclude_channel_ids = Vec::new();
|
||||
let mut selected = match (self.select_channel)(
|
||||
input.requested_model.clone(),
|
||||
input.downstream_policy.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(selected) => selected,
|
||||
None => {
|
||||
return ExecuteResult::Failure {
|
||||
reason: FailureReason::NoChannel,
|
||||
selected: None,
|
||||
status: None,
|
||||
raw_error_text: None,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
if attempts >= MAX_ATTEMPTS {
|
||||
return exhausted_failure(
|
||||
selected,
|
||||
None,
|
||||
None,
|
||||
attempts,
|
||||
input.on_terminal_failure.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let ctx = ExecuteAttemptContext {
|
||||
selected: selected.clone(),
|
||||
attempt_index: attempts,
|
||||
exclude_channel_ids: exclude_channel_ids.clone(),
|
||||
};
|
||||
attempts += 1;
|
||||
|
||||
match (input.attempt_fn)(ctx).await {
|
||||
AttemptResult::Success {
|
||||
response,
|
||||
latency_ms,
|
||||
cost,
|
||||
} => {
|
||||
(self.record_success)(
|
||||
selected.channel_id,
|
||||
SuccessMetrics {
|
||||
latency_ms,
|
||||
cost,
|
||||
metadata: Default::default(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return ExecuteResult::Success {
|
||||
selected,
|
||||
response,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
AttemptResult::Failure {
|
||||
action,
|
||||
status,
|
||||
raw_error_text,
|
||||
} => {
|
||||
let classified = if matches!(action, AttemptFailureAction::Stop) {
|
||||
AttemptFailureAction::Stop
|
||||
} else {
|
||||
classify_failure(status, raw_error_text.as_deref())
|
||||
};
|
||||
let info = FailureInfo {
|
||||
action: classified.as_str().to_string(),
|
||||
status,
|
||||
raw_error_text: raw_error_text.clone(),
|
||||
};
|
||||
(self.record_failure)(selected.channel_id, info.clone()).await;
|
||||
|
||||
match classified {
|
||||
AttemptFailureAction::RetrySameChannel => {
|
||||
continue;
|
||||
}
|
||||
AttemptFailureAction::RefreshAuth => {
|
||||
if let Some(refresh_auth) = &self.refresh_auth {
|
||||
if let Some(refreshed) =
|
||||
refresh_auth(selected.clone(), info.clone()).await
|
||||
{
|
||||
selected = refreshed;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
exclude_channel_ids.push(selected.channel_id);
|
||||
match (self.select_next_channel)(
|
||||
input.requested_model.clone(),
|
||||
exclude_channel_ids.clone(),
|
||||
input.downstream_policy.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(next) => {
|
||||
selected = next;
|
||||
}
|
||||
None => {
|
||||
return terminal_failure(
|
||||
selected,
|
||||
FailureReason::Failed,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
input.on_terminal_failure.as_deref(),
|
||||
&info,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
AttemptFailureAction::Failover => {
|
||||
exclude_channel_ids.push(selected.channel_id);
|
||||
match (self.select_next_channel)(
|
||||
input.requested_model.clone(),
|
||||
exclude_channel_ids.clone(),
|
||||
input.downstream_policy.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(next) => {
|
||||
selected = next;
|
||||
}
|
||||
None => {
|
||||
return terminal_failure(
|
||||
selected,
|
||||
FailureReason::Failed,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
input.on_terminal_failure.as_deref(),
|
||||
&info,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
AttemptFailureAction::Terminal => {
|
||||
return terminal_failure(
|
||||
selected,
|
||||
FailureReason::Terminal,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
input.on_terminal_failure.as_deref(),
|
||||
&info,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
AttemptFailureAction::Stop => {
|
||||
return terminal_failure(
|
||||
selected,
|
||||
FailureReason::Failed,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
input.on_terminal_failure.as_deref(),
|
||||
&info,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn terminal_failure(
|
||||
selected: SelectedChannel,
|
||||
reason: FailureReason,
|
||||
status: Option<u16>,
|
||||
raw_error_text: Option<String>,
|
||||
attempts: usize,
|
||||
on_terminal_failure: Option<
|
||||
&(dyn Fn(SelectedChannel, AttemptFailure) -> BoxFuture<'static, ()> + Send + Sync),
|
||||
>,
|
||||
info: &FailureInfo,
|
||||
) -> ExecuteResult {
|
||||
if let Some(callback) = on_terminal_failure {
|
||||
callback(selected.clone(), AttemptFailure::from(info)).await;
|
||||
}
|
||||
ExecuteResult::Failure {
|
||||
reason,
|
||||
selected: Some(selected),
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
}
|
||||
}
|
||||
|
||||
async fn exhausted_failure(
|
||||
selected: SelectedChannel,
|
||||
status: Option<u16>,
|
||||
raw_error_text: Option<String>,
|
||||
attempts: usize,
|
||||
on_terminal_failure: Option<
|
||||
&(dyn Fn(SelectedChannel, AttemptFailure) -> BoxFuture<'static, ()> + Send + Sync),
|
||||
>,
|
||||
) -> ExecuteResult {
|
||||
let info = FailureInfo {
|
||||
action: AttemptFailureAction::Stop.as_str().to_string(),
|
||||
status,
|
||||
raw_error_text: raw_error_text.clone(),
|
||||
};
|
||||
terminal_failure(
|
||||
selected,
|
||||
FailureReason::Failed,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
on_terminal_failure,
|
||||
&info,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::FutureExt;
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn channel(id: i64) -> SelectedChannel {
|
||||
SelectedChannel {
|
||||
channel_id: id,
|
||||
route_id: Some(10),
|
||||
site_id: 20,
|
||||
account_id: Some(30),
|
||||
base_url: "https://example.test/v1".into(),
|
||||
api_key: "key".into(),
|
||||
model: "gpt-5.5".into(),
|
||||
actual_model: "gpt-5.5".into(),
|
||||
token_name: None,
|
||||
token_value: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retries_same_channel_then_succeeds() {
|
||||
let attempts_seen = Arc::new(AtomicUsize::new(0));
|
||||
let recorded_failures = Arc::new(AtomicUsize::new(0));
|
||||
let recorded_successes = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let conductor = ProxyConductor {
|
||||
select_channel: Arc::new(|_, _| async { Some(channel(1)) }.boxed()),
|
||||
select_next_channel: Arc::new(|_, _, _| async { Some(channel(2)) }.boxed()),
|
||||
record_success: {
|
||||
let recorded_successes = Arc::clone(&recorded_successes);
|
||||
Arc::new(move |_, _| {
|
||||
let recorded_successes = Arc::clone(&recorded_successes);
|
||||
async move {
|
||||
recorded_successes.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
.boxed()
|
||||
})
|
||||
},
|
||||
record_failure: {
|
||||
let recorded_failures = Arc::clone(&recorded_failures);
|
||||
Arc::new(move |_, _| {
|
||||
let recorded_failures = Arc::clone(&recorded_failures);
|
||||
async move {
|
||||
recorded_failures.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
.boxed()
|
||||
})
|
||||
},
|
||||
refresh_auth: None,
|
||||
};
|
||||
|
||||
let result = conductor
|
||||
.execute(ExecuteInput {
|
||||
requested_model: "gpt-5.5".into(),
|
||||
downstream_policy: None,
|
||||
attempt_fn: Box::new({
|
||||
let attempts_seen = Arc::clone(&attempts_seen);
|
||||
move |_| {
|
||||
let attempt = attempts_seen.fetch_add(1, Ordering::SeqCst);
|
||||
async move {
|
||||
if attempt == 0 {
|
||||
AttemptResult::Failure {
|
||||
action: AttemptFailureAction::RetrySameChannel,
|
||||
status: Some(429),
|
||||
raw_error_text: Some("rate limited".into()),
|
||||
}
|
||||
} else {
|
||||
AttemptResult::Success {
|
||||
response: json!({"ok": true}),
|
||||
latency_ms: Some(12),
|
||||
cost: Some(0.1),
|
||||
}
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
}),
|
||||
on_terminal_failure: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
ExecuteResult::Success {
|
||||
selected, attempts, ..
|
||||
} => {
|
||||
assert_eq!(selected.channel_id, 1);
|
||||
assert_eq!(attempts, 2);
|
||||
}
|
||||
other => panic!("expected success, got {other:?}"),
|
||||
}
|
||||
assert_eq!(recorded_failures.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(recorded_successes.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failover_selects_next_channel() {
|
||||
let conductor = ProxyConductor {
|
||||
select_channel: Arc::new(|_, _| async { Some(channel(1)) }.boxed()),
|
||||
select_next_channel: Arc::new(|_, excluded, _| {
|
||||
async move {
|
||||
assert_eq!(excluded, vec![1]);
|
||||
Some(channel(2))
|
||||
}
|
||||
.boxed()
|
||||
}),
|
||||
record_success: Arc::new(|_, _| async {}.boxed()),
|
||||
record_failure: Arc::new(|_, _| async {}.boxed()),
|
||||
refresh_auth: None,
|
||||
};
|
||||
|
||||
let attempts_seen = Arc::new(AtomicUsize::new(0));
|
||||
let result = conductor
|
||||
.execute(ExecuteInput {
|
||||
requested_model: "gpt-5.5".into(),
|
||||
downstream_policy: None,
|
||||
attempt_fn: Box::new({
|
||||
let attempts_seen = Arc::clone(&attempts_seen);
|
||||
move |ctx| {
|
||||
let attempt = attempts_seen.fetch_add(1, Ordering::SeqCst);
|
||||
async move {
|
||||
if attempt == 0 {
|
||||
assert_eq!(ctx.selected.channel_id, 1);
|
||||
AttemptResult::Failure {
|
||||
action: AttemptFailureAction::Failover,
|
||||
status: Some(500),
|
||||
raw_error_text: None,
|
||||
}
|
||||
} else {
|
||||
assert_eq!(ctx.selected.channel_id, 2);
|
||||
AttemptResult::Success {
|
||||
response: json!({"ok": true}),
|
||||
latency_ms: None,
|
||||
cost: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
}),
|
||||
on_terminal_failure: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
ExecuteResult::Success {
|
||||
selected, attempts, ..
|
||||
} => {
|
||||
assert_eq!(selected.channel_id, 2);
|
||||
assert_eq!(attempts, 2);
|
||||
}
|
||||
other => panic!("expected success, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
pub mod conductor;
|
||||
pub mod retry_policy;
|
||||
pub mod types;
|
||||
|
||||
pub use conductor::ProxyConductor;
|
||||
pub use retry_policy::{
|
||||
classify_failure, is_terminal, should_failover, should_refresh_auth, should_retry_same_channel,
|
||||
};
|
||||
pub use types::{
|
||||
AttemptFailure, AttemptFailureAction, AttemptResult, ExecuteAttemptContext, ExecuteInput,
|
||||
ExecuteResult, FailureInfo, FailureReason, SelectedChannel, SuccessMetrics,
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
use super::types::AttemptFailureAction;
|
||||
|
||||
pub fn classify_failure(status: Option<u16>, _error_text: Option<&str>) -> AttemptFailureAction {
|
||||
match status {
|
||||
Some(429 | 503) => AttemptFailureAction::RetrySameChannel,
|
||||
Some(401) => AttemptFailureAction::RefreshAuth,
|
||||
Some(code) if (500..600).contains(&code) => AttemptFailureAction::Failover,
|
||||
Some(code) if (400..500).contains(&code) => AttemptFailureAction::Terminal,
|
||||
_ => AttemptFailureAction::Failover,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_terminal(action: AttemptFailureAction) -> bool {
|
||||
matches!(action, AttemptFailureAction::Terminal)
|
||||
}
|
||||
|
||||
pub fn should_retry_same_channel(action: AttemptFailureAction) -> bool {
|
||||
matches!(action, AttemptFailureAction::RetrySameChannel)
|
||||
}
|
||||
|
||||
pub fn should_refresh_auth(action: AttemptFailureAction) -> bool {
|
||||
matches!(action, AttemptFailureAction::RefreshAuth)
|
||||
}
|
||||
|
||||
pub fn should_failover(action: AttemptFailureAction) -> bool {
|
||||
matches!(action, AttemptFailureAction::Failover)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classifies_retryable_rate_limit_and_unavailable() {
|
||||
assert_eq!(
|
||||
classify_failure(Some(429), None),
|
||||
AttemptFailureAction::RetrySameChannel
|
||||
);
|
||||
assert_eq!(
|
||||
classify_failure(Some(503), Some("busy")),
|
||||
AttemptFailureAction::RetrySameChannel
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_auth_server_and_terminal_failures() {
|
||||
assert_eq!(
|
||||
classify_failure(Some(401), None),
|
||||
AttemptFailureAction::RefreshAuth
|
||||
);
|
||||
assert_eq!(
|
||||
classify_failure(Some(500), None),
|
||||
AttemptFailureAction::Failover
|
||||
);
|
||||
assert_eq!(
|
||||
classify_failure(Some(502), None),
|
||||
AttemptFailureAction::Failover
|
||||
);
|
||||
assert_eq!(
|
||||
classify_failure(Some(400), None),
|
||||
AttemptFailureAction::Terminal
|
||||
);
|
||||
assert_eq!(
|
||||
classify_failure(Some(404), None),
|
||||
AttemptFailureAction::Terminal
|
||||
);
|
||||
assert_eq!(
|
||||
classify_failure(None, Some("timeout")),
|
||||
AttemptFailureAction::Failover
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_predicates_match_actions() {
|
||||
assert!(is_terminal(AttemptFailureAction::Terminal));
|
||||
assert!(should_retry_same_channel(
|
||||
AttemptFailureAction::RetrySameChannel
|
||||
));
|
||||
assert!(should_refresh_auth(AttemptFailureAction::RefreshAuth));
|
||||
assert!(should_failover(AttemptFailureAction::Failover));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use futures::future::BoxFuture;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SelectedChannel {
|
||||
pub channel_id: i64,
|
||||
pub route_id: Option<i64>,
|
||||
pub site_id: i64,
|
||||
pub account_id: Option<i64>,
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub model: String,
|
||||
pub actual_model: String,
|
||||
pub token_name: Option<String>,
|
||||
pub token_value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AttemptFailureAction {
|
||||
RetrySameChannel,
|
||||
RefreshAuth,
|
||||
Failover,
|
||||
Terminal,
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AttemptResult {
|
||||
Success {
|
||||
response: Value,
|
||||
latency_ms: Option<u64>,
|
||||
cost: Option<f64>,
|
||||
},
|
||||
Failure {
|
||||
action: AttemptFailureAction,
|
||||
status: Option<u16>,
|
||||
raw_error_text: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct ExecuteInput {
|
||||
pub requested_model: String,
|
||||
pub downstream_policy: Option<Value>,
|
||||
pub attempt_fn: Box<dyn Fn(ExecuteAttemptContext) -> BoxFuture<'static, AttemptResult> + Send>,
|
||||
pub on_terminal_failure: Option<
|
||||
Box<dyn Fn(SelectedChannel, AttemptFailure) -> BoxFuture<'static, ()> + Send + Sync>,
|
||||
>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecuteAttemptContext {
|
||||
pub selected: SelectedChannel,
|
||||
pub attempt_index: usize,
|
||||
pub exclude_channel_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ExecuteResult {
|
||||
Success {
|
||||
selected: SelectedChannel,
|
||||
response: Value,
|
||||
attempts: usize,
|
||||
},
|
||||
Failure {
|
||||
reason: FailureReason,
|
||||
selected: Option<SelectedChannel>,
|
||||
status: Option<u16>,
|
||||
raw_error_text: Option<String>,
|
||||
attempts: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FailureReason {
|
||||
NoChannel,
|
||||
Failed,
|
||||
Terminal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SuccessMetrics {
|
||||
pub latency_ms: Option<u64>,
|
||||
pub cost: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FailureInfo {
|
||||
pub action: String,
|
||||
pub status: Option<u16>,
|
||||
pub raw_error_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AttemptFailure {
|
||||
pub status: Option<u16>,
|
||||
pub raw_error_text: Option<String>,
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
impl From<&FailureInfo> for AttemptFailure {
|
||||
fn from(value: &FailureInfo) -> Self {
|
||||
Self {
|
||||
status: value.status,
|
||||
raw_error_text: value.raw_error_text.clone(),
|
||||
action: value.action.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AttemptFailureAction {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::RetrySameChannel => "retry_same_channel",
|
||||
Self::RefreshAuth => "refresh_auth",
|
||||
Self::Failover => "failover",
|
||||
Self::Terminal => "terminal",
|
||||
Self::Stop => "stop",
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user