mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-02 01:08:41 +02:00
feat: remote sessions
This commit is contained in:
+275
-54
@@ -40,6 +40,49 @@ pub struct ApiProfile {
|
||||
pub proxy_bypass_rules: Vec<String>,
|
||||
pub vpn_id: Option<String>,
|
||||
pub clear_on_close: bool,
|
||||
/// Cloud sync mode: `"Disabled"`, `"Regular"` or `"Encrypted"`.
|
||||
/// Settable via `PUT /v1/profiles/{id}`; exposed here so a caller can read
|
||||
/// back what it set, and so a remote-launch caller can tell whether the
|
||||
/// profile is actually available in cloud storage.
|
||||
pub sync_mode: String,
|
||||
/// Convenience form of `sync_mode` — true for Regular or Encrypted.
|
||||
pub cloud_sync_enabled: bool,
|
||||
/// OS the profile was created on (`"macos"`, `"windows"`, `"linux"`).
|
||||
/// `null` when neither `host_os` nor the browser config records one.
|
||||
pub host_os: Option<String>,
|
||||
/// True when the profile belongs to a different OS than this machine.
|
||||
/// Such a profile cannot be launched locally, and must only ever run on a
|
||||
/// remote host of its own OS — Chromium profile state is OS-specific.
|
||||
pub is_cross_os: bool,
|
||||
}
|
||||
|
||||
impl From<&crate::profile::types::BrowserProfile> for ApiProfile {
|
||||
/// Single conversion for every profile-returning route. Previously open-coded
|
||||
/// at three call sites, which is how `sync_mode` came to be settable but not
|
||||
/// readable: a field added to the struct had to be remembered three times.
|
||||
fn from(profile: &crate::profile::types::BrowserProfile) -> Self {
|
||||
Self {
|
||||
id: profile.id.to_string(),
|
||||
name: profile.name.clone(),
|
||||
browser: profile.browser.clone(),
|
||||
version: profile.version.clone(),
|
||||
proxy_id: profile.proxy_id.clone(),
|
||||
launch_hook: profile.launch_hook.clone(),
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type.clone(),
|
||||
group_id: profile.group_id.clone(),
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(),
|
||||
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
||||
vpn_id: profile.vpn_id.clone(),
|
||||
clear_on_close: profile.clear_on_close,
|
||||
sync_mode: format!("{:?}", profile.sync_mode),
|
||||
cloud_sync_enabled: profile.is_sync_enabled(),
|
||||
host_os: profile.resolved_os().map(|os| os.to_string()),
|
||||
is_cross_os: profile.is_cross_os(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
@@ -215,6 +258,22 @@ struct RunProfileResponse {
|
||||
headless: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct RunRemoteRequest {
|
||||
/// Optional URL to open once the remote browser is up.
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct RunRemoteResponse {
|
||||
pub profile_id: String,
|
||||
/// Remote session id, for polling or closing the session.
|
||||
pub session_id: String,
|
||||
/// Operating system the session was scheduled onto — always the profile's own.
|
||||
pub platform: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct RunProfileRequest {
|
||||
url: Option<String>,
|
||||
@@ -395,6 +454,8 @@ struct ImportProxiesResponse {
|
||||
DownloadBrowserRequest,
|
||||
DownloadBrowserResponse,
|
||||
RunProfileResponse,
|
||||
RunRemoteRequest,
|
||||
RunRemoteResponse,
|
||||
RunProfileRequest,
|
||||
BatchRunRequest,
|
||||
BatchRunResult,
|
||||
@@ -511,6 +572,7 @@ impl ApiServer {
|
||||
.routes(routes!(get_profiles, create_profile))
|
||||
.routes(routes!(get_profile, update_profile, delete_profile))
|
||||
.routes(routes!(run_profile))
|
||||
.routes(routes!(run_profile_remote))
|
||||
.routes(routes!(open_url_in_profile))
|
||||
.routes(routes!(kill_profile))
|
||||
.routes(routes!(batch_run_profiles))
|
||||
@@ -860,26 +922,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
match profile_manager.list_profiles() {
|
||||
Ok(profiles) => {
|
||||
let api_profiles: Vec<ApiProfile> = profiles
|
||||
.iter()
|
||||
.map(|profile| ApiProfile {
|
||||
id: profile.id.to_string(),
|
||||
name: profile.name.clone(),
|
||||
browser: profile.browser.clone(),
|
||||
version: profile.version.clone(),
|
||||
proxy_id: profile.proxy_id.clone(),
|
||||
launch_hook: profile.launch_hook.clone(),
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type.clone(),
|
||||
group_id: profile.group_id.clone(),
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
||||
vpn_id: profile.vpn_id.clone(),
|
||||
clear_on_close: profile.clear_on_close,
|
||||
})
|
||||
.collect();
|
||||
let api_profiles: Vec<ApiProfile> = profiles.iter().map(ApiProfile::from).collect();
|
||||
|
||||
Ok(Json(ApiProfilesResponse {
|
||||
profiles: api_profiles,
|
||||
@@ -916,23 +959,7 @@ async fn get_profile(
|
||||
Ok(profiles) => {
|
||||
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
|
||||
Ok(Json(ApiProfileResponse {
|
||||
profile: ApiProfile {
|
||||
id: profile.id.to_string(),
|
||||
name: profile.name.clone(),
|
||||
browser: profile.browser.clone(),
|
||||
version: profile.version.clone(),
|
||||
proxy_id: profile.proxy_id.clone(),
|
||||
launch_hook: profile.launch_hook.clone(),
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type.clone(),
|
||||
group_id: profile.group_id.clone(),
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
||||
vpn_id: profile.vpn_id.clone(),
|
||||
clear_on_close: profile.clear_on_close,
|
||||
},
|
||||
profile: ApiProfile::from(profile),
|
||||
}))
|
||||
} else {
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
@@ -1081,23 +1108,7 @@ async fn create_profile(
|
||||
}
|
||||
|
||||
Ok(Json(ApiProfileResponse {
|
||||
profile: ApiProfile {
|
||||
id: profile.id.to_string(),
|
||||
name: profile.name,
|
||||
browser: profile.browser,
|
||||
version: profile.version,
|
||||
proxy_id: profile.proxy_id,
|
||||
launch_hook: profile.launch_hook,
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type,
|
||||
group_id: profile.group_id,
|
||||
tags: profile.tags,
|
||||
is_running: false,
|
||||
proxy_bypass_rules: profile.proxy_bypass_rules,
|
||||
vpn_id: profile.vpn_id,
|
||||
clear_on_close: profile.clear_on_close,
|
||||
},
|
||||
profile: ApiProfile::from(&profile),
|
||||
}))
|
||||
}
|
||||
Err(e) => Err((
|
||||
@@ -2141,6 +2152,111 @@ async fn run_profile(
|
||||
}
|
||||
}
|
||||
|
||||
// API Handler - Launch this profile on a REMOTE VM of its own operating system
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/profiles/{id}/run-remote",
|
||||
params(
|
||||
("id" = String, Path, description = "Profile ID")
|
||||
),
|
||||
request_body = RunRemoteRequest,
|
||||
responses(
|
||||
(status = 200, description = "Remote session started", body = RunRemoteResponse),
|
||||
(status = 400, description = "Profile does not have cloud sync enabled"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 409, description = "Profile is locked by another session"),
|
||||
(status = 429, description = "Automation request rate limit exceeded"),
|
||||
(status = 503, description = "No remote capacity for this operating system"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "profiles"
|
||||
)]
|
||||
async fn run_profile_remote(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<RunRemoteRequest>,
|
||||
) -> Result<Json<RunRemoteResponse>, (StatusCode, String)> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
|
||||
}
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(manager_error_response)?;
|
||||
let profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.id.to_string() == id)
|
||||
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
|
||||
|
||||
// The profile must exist in cloud storage before a remote host can open it —
|
||||
// the VM pulls it from donut-sync, and a profile that has never synced would
|
||||
// launch an empty browser and then push that emptiness back over the real one.
|
||||
if let Err(reason) = remote_launch_precondition(profile) {
|
||||
return Err((StatusCode::BAD_REQUEST, reason));
|
||||
}
|
||||
|
||||
// Deliberately NO is_cross_os() guard here. Local /run refuses a foreign
|
||||
// profile because this machine is the wrong OS; running it remotely on a host
|
||||
// of its OWN OS is precisely what this endpoint exists for.
|
||||
let outcome =
|
||||
crate::remote_session::start_remote_session(state.app_handle.clone(), profile, request.url)
|
||||
.await
|
||||
.map_err(remote_session_error_response)?;
|
||||
|
||||
Ok(Json(RunRemoteResponse {
|
||||
profile_id: profile.id.to_string(),
|
||||
session_id: outcome.session_id,
|
||||
platform: outcome.platform,
|
||||
status: outcome.status,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Whether a profile may be launched on a remote host.
|
||||
///
|
||||
/// Extracted so the rule is unit-testable without a running app: it is the one
|
||||
/// gate between "the user asked" and "a browser opens somewhere else holding
|
||||
/// their cookies".
|
||||
pub fn remote_launch_precondition(
|
||||
profile: &crate::profile::types::BrowserProfile,
|
||||
) -> Result<(), String> {
|
||||
if !profile.is_sync_enabled() {
|
||||
return Err(
|
||||
"profile does not have cloud sync enabled; a remote host has no way to \
|
||||
obtain it"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if profile.resolved_os().is_none() {
|
||||
return Err(
|
||||
"profile has no recorded operating system, so it cannot be scheduled \
|
||||
onto a matching host"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remote_session_error_response(
|
||||
err: crate::remote_session::RemoteSessionError,
|
||||
) -> (StatusCode, String) {
|
||||
use crate::remote_session::RemoteSessionError;
|
||||
match err {
|
||||
RemoteSessionError::NoCapacity(m) => (StatusCode::SERVICE_UNAVAILABLE, m),
|
||||
RemoteSessionError::Conflict(m) => (StatusCode::CONFLICT, m),
|
||||
RemoteSessionError::NotAuthorised(m) => (StatusCode::PAYMENT_REQUIRED, m),
|
||||
RemoteSessionError::Other(m) => (StatusCode::INTERNAL_SERVER_ERROR, m),
|
||||
}
|
||||
}
|
||||
|
||||
// API Handler - Open URL in existing browser
|
||||
#[utoipa::path(
|
||||
post,
|
||||
@@ -2660,6 +2776,111 @@ async fn check_browser_downloaded(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::profile::types::{BrowserProfile, SyncMode};
|
||||
|
||||
fn profile_with(sync_mode: SyncMode, host_os: Option<&str>) -> BrowserProfile {
|
||||
BrowserProfile {
|
||||
id: uuid::Uuid::nil(),
|
||||
name: "p".to_string(),
|
||||
browser: "wayfern".to_string(),
|
||||
version: "latest".to_string(),
|
||||
sync_mode,
|
||||
host_os: host_os.map(|s| s.to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// Cloud sync has been settable through PUT /v1/profiles/{id} but was absent
|
||||
// from every profile RESPONSE, so a caller could turn it on and never
|
||||
// confirm it. A remote-launch caller must be able to see this before it can
|
||||
// decide whether the profile exists in cloud storage at all.
|
||||
// /run-remote exists precisely so a profile can run on a host of ITS OWN OS
|
||||
// when this machine is the wrong one. The gate is cloud sync: a remote host
|
||||
// obtains the profile from donut-sync, so a profile that has never synced
|
||||
// would launch an empty browser and push that emptiness over the real one.
|
||||
#[test]
|
||||
fn remote_launch_requires_cloud_sync() {
|
||||
let err = remote_launch_precondition(&profile_with(SyncMode::Disabled, Some("macos")))
|
||||
.expect_err("a non-synced profile must be refused");
|
||||
assert!(err.contains("cloud sync"), "unhelpful message: {err}");
|
||||
|
||||
for mode in [SyncMode::Regular, SyncMode::Encrypted] {
|
||||
assert!(
|
||||
remote_launch_precondition(&profile_with(mode, Some("macos"))).is_ok(),
|
||||
"a synced profile must be allowed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_launch_requires_a_known_operating_system() {
|
||||
// Without one there is no way to pick a matching host, and guessing would
|
||||
// be the cross-OS mismatch this whole design exists to prevent.
|
||||
assert!(remote_launch_precondition(&profile_with(SyncMode::Regular, None)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_launch_allows_a_cross_os_profile() {
|
||||
let host = crate::profile::types::get_host_os();
|
||||
let other = if host == "windows" {
|
||||
"macos"
|
||||
} else {
|
||||
"windows"
|
||||
};
|
||||
let foreign = profile_with(SyncMode::Regular, Some(other));
|
||||
|
||||
assert!(
|
||||
foreign.is_cross_os(),
|
||||
"test setup: profile should be foreign"
|
||||
);
|
||||
// Local /run refuses this; running it remotely on a host of its own OS is
|
||||
// exactly what /run-remote is for.
|
||||
assert!(remote_launch_precondition(&foreign).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_profile_exposes_cloud_sync_state() {
|
||||
let disabled = ApiProfile::from(&profile_with(SyncMode::Disabled, None));
|
||||
assert_eq!(disabled.sync_mode, "Disabled");
|
||||
assert!(!disabled.cloud_sync_enabled);
|
||||
|
||||
let regular = ApiProfile::from(&profile_with(SyncMode::Regular, None));
|
||||
assert_eq!(regular.sync_mode, "Regular");
|
||||
assert!(regular.cloud_sync_enabled);
|
||||
|
||||
let encrypted = ApiProfile::from(&profile_with(SyncMode::Encrypted, None));
|
||||
assert_eq!(encrypted.sync_mode, "Encrypted");
|
||||
assert!(encrypted.cloud_sync_enabled);
|
||||
}
|
||||
|
||||
// A profile must only ever run on its own operating system: Chromium's
|
||||
// on-disk state is OS-specific, so replaying a macOS profile on Windows is a
|
||||
// mismatch no amount of user-agent spoofing repairs.
|
||||
#[test]
|
||||
fn api_profile_reports_its_operating_system() {
|
||||
let host = crate::profile::types::get_host_os();
|
||||
let same = ApiProfile::from(&profile_with(SyncMode::Regular, Some(&host)));
|
||||
assert_eq!(same.host_os.as_deref(), Some(host.as_str()));
|
||||
assert!(!same.is_cross_os);
|
||||
|
||||
let other = if host == "windows" {
|
||||
"macos"
|
||||
} else {
|
||||
"windows"
|
||||
};
|
||||
let foreign = ApiProfile::from(&profile_with(SyncMode::Regular, Some(other)));
|
||||
assert_eq!(foreign.host_os.as_deref(), Some(other));
|
||||
assert!(foreign.is_cross_os);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_profile_without_a_recorded_os_is_not_cross_os() {
|
||||
// An older profile that predates host_os must stay locally launchable
|
||||
// rather than being treated as foreign.
|
||||
let unknown = ApiProfile::from(&profile_with(SyncMode::Disabled, None));
|
||||
assert_eq!(unknown.host_os, None);
|
||||
assert!(!unknown.is_cross_os);
|
||||
}
|
||||
|
||||
// Removing `browser` from UpdateProfileRequest, and rejecting invalid
|
||||
// `browser` values on create, must NOT make the API reject requests that
|
||||
|
||||
@@ -72,6 +72,7 @@ mod proxy_manager;
|
||||
pub mod proxy_runner;
|
||||
pub mod proxy_server;
|
||||
pub mod proxy_storage;
|
||||
mod remote_session;
|
||||
mod settings_manager;
|
||||
pub mod socks5_local;
|
||||
pub mod sync;
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Launching a profile on a remote VM.
|
||||
//!
|
||||
//! The desktop app never talks to the Wayfern manager directly. It asks
|
||||
//! donutbrowser-infra, which holds the service-account credentials and is the
|
||||
//! only party that can mint a donut-sync token scoped to this user's namespace.
|
||||
//! That indirection is the point: a desktop client that could call the manager
|
||||
//! itself would need credentials capable of launching sessions for anyone.
|
||||
|
||||
use crate::profile::types::BrowserProfile;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
|
||||
/// Why a remote launch failed, mapped to the status the local API should return.
|
||||
#[derive(Debug)]
|
||||
pub enum RemoteSessionError {
|
||||
/// No host of the profile's OS has a free slot right now.
|
||||
NoCapacity(String),
|
||||
/// The profile is already open somewhere — locally or in another session.
|
||||
Conflict(String),
|
||||
/// The user's plan does not cover remote automation.
|
||||
NotAuthorised(String),
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RemoteSessionError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NoCapacity(m) | Self::Conflict(m) | Self::NotAuthorised(m) | Self::Other(m) => {
|
||||
write!(f, "{m}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the backend returns when a session starts.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoteSessionOutcome {
|
||||
pub session_id: String,
|
||||
pub platform: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct StartRemoteRequest {
|
||||
profile_id: String,
|
||||
/// The profile's own OS. The backend refuses to schedule it anywhere else.
|
||||
platform: String,
|
||||
/// Set when the caller wants a page opened once the browser is up.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
url: Option<String>,
|
||||
/// De-duplicates retries so a flaky network cannot open two browsers against
|
||||
/// one profile.
|
||||
idempotency_key: String,
|
||||
}
|
||||
|
||||
/// Map a backend status onto a typed error.
|
||||
///
|
||||
/// Kept separate from the request so the mapping is testable: getting 503
|
||||
/// wrong would turn "come back in a minute" into "something is broken",
|
||||
/// and getting 409 wrong would hide the fact that the profile is already open.
|
||||
pub fn classify_backend_status(status: u16, body: &str) -> RemoteSessionError {
|
||||
let message = if body.is_empty() {
|
||||
format!("remote session request failed with HTTP {status}")
|
||||
} else {
|
||||
body.to_string()
|
||||
};
|
||||
match status {
|
||||
503 => RemoteSessionError::NoCapacity(message),
|
||||
409 => RemoteSessionError::Conflict(message),
|
||||
401..=403 => RemoteSessionError::NotAuthorised(message),
|
||||
_ => RemoteSessionError::Other(message),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the idempotency key for one launch attempt.
|
||||
///
|
||||
/// Derived from the profile and a caller-supplied nonce rather than random, so
|
||||
/// a retry of the SAME user action de-duplicates while a genuinely new launch
|
||||
/// does not.
|
||||
pub fn idempotency_key(profile_id: &str, nonce: &str) -> String {
|
||||
format!("run-remote:{profile_id}:{nonce}")
|
||||
}
|
||||
|
||||
/// Ask donutbrowser-infra to start a remote session for this profile.
|
||||
///
|
||||
/// Goes through `api_call_with_retry` so an expired access token is refreshed
|
||||
/// and the request retried once, rather than surfacing to the user as a
|
||||
/// spurious "not signed in".
|
||||
pub async fn start_remote_session(
|
||||
_app: AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
url: Option<String>,
|
||||
) -> Result<RemoteSessionOutcome, RemoteSessionError> {
|
||||
let platform = profile
|
||||
.resolved_os()
|
||||
.ok_or_else(|| {
|
||||
RemoteSessionError::Other("profile has no recorded operating system".to_string())
|
||||
})?
|
||||
.to_string();
|
||||
let profile_id = profile.id.to_string();
|
||||
|
||||
// One key for this user action: a retry inside api_call_with_retry must
|
||||
// de-duplicate rather than open a second browser on the same profile.
|
||||
let key = idempotency_key(&profile_id, &uuid::Uuid::new_v4().to_string());
|
||||
let endpoint = format!("{}/api/remote-sessions", crate::cloud_auth::CLOUD_API_URL);
|
||||
|
||||
crate::cloud_auth::CLOUD_AUTH
|
||||
.api_call_with_retry(|token| {
|
||||
let endpoint = endpoint.clone();
|
||||
let body = StartRemoteRequest {
|
||||
profile_id: profile_id.clone(),
|
||||
platform: platform.clone(),
|
||||
url: url.clone(),
|
||||
idempotency_key: key.clone(),
|
||||
};
|
||||
async move {
|
||||
let response = reqwest::Client::new()
|
||||
.post(&endpoint)
|
||||
.bearer_auth(token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("reach backend: {e}"))?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
if !(200..300).contains(&status) {
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
// Encode the status so api_call_with_retry can spot a 401, and so
|
||||
// classify_backend_status can recover the kind afterwards.
|
||||
return Err(format!("({status}) {text}"));
|
||||
}
|
||||
response
|
||||
.json::<RemoteSessionOutcome>()
|
||||
.await
|
||||
.map_err(|e| format!("decode response: {e}"))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| classify_error_string(&e))
|
||||
}
|
||||
|
||||
/// Recover a typed error from `api_call_with_retry`'s string.
|
||||
///
|
||||
/// That helper flattens everything to `String` to do its 401 sniffing, so the
|
||||
/// status is re-parsed here rather than lost — a 503 surfacing as a generic
|
||||
/// failure would tell the user their fleet is broken when it is merely busy.
|
||||
pub fn classify_error_string(message: &str) -> RemoteSessionError {
|
||||
if let Some(rest) = message.strip_prefix('(') {
|
||||
if let Some((code, tail)) = rest.split_once(')') {
|
||||
if let Ok(status) = code.trim().parse::<u16>() {
|
||||
return classify_backend_status(status, tail.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
RemoteSessionError::Other(message.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_capacity_is_distinguished_from_a_real_failure() {
|
||||
// 503 means "come back in a minute", not "something is broken" — conflating
|
||||
// them would make a busy fleet look like an outage to the user.
|
||||
assert!(matches!(
|
||||
classify_backend_status(503, "no macos host free"),
|
||||
RemoteSessionError::NoCapacity(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
classify_backend_status(500, "boom"),
|
||||
RemoteSessionError::Other(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_is_surfaced_so_the_user_learns_the_profile_is_open() {
|
||||
assert!(matches!(
|
||||
classify_backend_status(409, "profile already has a live session"),
|
||||
RemoteSessionError::Conflict(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payment_and_auth_failures_map_to_not_authorised() {
|
||||
for status in [401u16, 402, 403] {
|
||||
assert!(
|
||||
matches!(
|
||||
classify_backend_status(status, ""),
|
||||
RemoteSessionError::NotAuthorised(_)
|
||||
),
|
||||
"status {status} should be NotAuthorised"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_body_still_produces_a_useful_message() {
|
||||
let err = classify_backend_status(500, "");
|
||||
assert!(err.to_string().contains("500"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_status_encoded_error_string_round_trips_to_its_kind() {
|
||||
// api_call_with_retry flattens everything to String to sniff for 401s; the
|
||||
// status must survive that or a busy fleet looks like a broken one.
|
||||
assert!(matches!(
|
||||
classify_error_string("(503) no macos host free"),
|
||||
RemoteSessionError::NoCapacity(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
classify_error_string("(409) already running"),
|
||||
RemoteSessionError::Conflict(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unencoded_error_string_is_not_misread_as_a_status() {
|
||||
assert!(matches!(
|
||||
classify_error_string("reach backend: connection refused"),
|
||||
RemoteSessionError::Other(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotency_key_is_stable_for_one_attempt_and_distinct_across_attempts() {
|
||||
let a = idempotency_key("p1", "nonce-1");
|
||||
assert_eq!(a, idempotency_key("p1", "nonce-1"));
|
||||
assert_ne!(a, idempotency_key("p1", "nonce-2"));
|
||||
assert_ne!(a, idempotency_key("p2", "nonce-1"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user