mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-15 14:15:31 +02:00
439 lines
15 KiB
Rust
439 lines
15 KiB
Rust
//! Turning a donutbrowser-infra HTTP failure into a stable, translatable code.
|
|
//!
|
|
//! Every cloud transport in this crate flattens its failures through
|
|
//! `api_call_with_retry`, which needs a `String` so it can sniff for a 401.
|
|
//! That flattening loses the status, and the body it carries is the backend's
|
|
//! own English — which would reach the user untranslated, the exact bug the
|
|
//! `{"code":…}` convention exists to prevent.
|
|
//!
|
|
//! So the backend sends a machine code, this module recovers it, and the
|
|
//! frontend resolves it through `translateBackendError`. When the backend
|
|
//! sends something else (a proxy error page, a gateway 502), the status alone
|
|
//! still picks a code the user can act on.
|
|
|
|
use serde_json::Value;
|
|
use std::collections::BTreeMap;
|
|
|
|
/// A backend failure reduced to the shape `translateBackendError` consumes.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct BackendFailure {
|
|
/// The HTTP status it came from. 0 when the request never got that far.
|
|
pub status: u16,
|
|
pub code: String,
|
|
pub params: BTreeMap<String, String>,
|
|
}
|
|
|
|
impl BackendFailure {
|
|
/// Render as the `{"code":…,"params":{…}}` string a Tauri command returns.
|
|
pub fn to_error_json(&self) -> String {
|
|
let mut object = serde_json::Map::new();
|
|
object.insert("code".to_string(), Value::String(self.code.clone()));
|
|
if !self.params.is_empty() {
|
|
let params = self
|
|
.params
|
|
.iter()
|
|
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
|
|
.collect::<serde_json::Map<_, _>>();
|
|
object.insert("params".to_string(), Value::Object(params));
|
|
}
|
|
Value::Object(object).to_string()
|
|
}
|
|
}
|
|
|
|
/// Which code a status maps to when the body carries none.
|
|
///
|
|
/// 404 and 409 mean different things per route — "no schedule for this
|
|
/// profile" and "that run id is not yours" are both 404 — so each caller
|
|
/// supplies its own, rather than every route sharing one vague code.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct FailureCodes {
|
|
pub bad_request: &'static str,
|
|
pub forbidden: &'static str,
|
|
pub not_found: &'static str,
|
|
pub conflict: &'static str,
|
|
}
|
|
|
|
/// The desktop has no cloud session at all.
|
|
pub const NOT_SIGNED_IN: &str = "CLOUD_NOT_SIGNED_IN";
|
|
/// The request never reached donutbrowser-infra.
|
|
pub const UNREACHABLE: &str = "CLOUD_UNREACHABLE";
|
|
/// The backend answered, but with nothing the user can act on.
|
|
pub const UNAVAILABLE: &str = "CLOUD_REQUEST_FAILED";
|
|
/// Too many automation requests, backend side.
|
|
pub const RATE_LIMITED: &str = "REMOTE_RATE_LIMITED";
|
|
/// No host of the profile's OS has a free slot.
|
|
pub const NO_CAPACITY: &str = "REMOTE_NO_CAPACITY";
|
|
|
|
/// Recover `(status, body)` from the string `api_call_with_retry` hands back.
|
|
///
|
|
/// The transports encode a non-2xx as `"(503) no macos host free"` so the
|
|
/// helper can spot a 401 and still let the caller recover the kind. Anything
|
|
/// that is not that shape is a transport failure, not a status.
|
|
pub fn split_status(message: &str) -> Option<(u16, &str)> {
|
|
let rest = message.strip_prefix('(')?;
|
|
let (code, tail) = rest.split_once(')')?;
|
|
let status = code.trim().parse::<u16>().ok()?;
|
|
Some((status, tail.trim()))
|
|
}
|
|
|
|
/// Classify one HTTP failure.
|
|
pub fn classify(status: u16, body: &str, codes: FailureCodes) -> BackendFailure {
|
|
if let Some(failure) = from_body(status, body) {
|
|
return failure;
|
|
}
|
|
BackendFailure {
|
|
status,
|
|
code: code_for_status(status, codes).to_string(),
|
|
params: BTreeMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Classify a flattened error string, whether or not it encodes a status.
|
|
///
|
|
/// Some callers strip the status before they get here (a typed error that
|
|
/// kept only the body), so a bare `{"code":…}` envelope is still recognised.
|
|
pub fn classify_message(message: &str, codes: FailureCodes) -> BackendFailure {
|
|
if let Some((status, body)) = split_status(message) {
|
|
return classify(status, body, codes);
|
|
}
|
|
if let Some(failure) = from_body(0, message) {
|
|
return failure;
|
|
}
|
|
transport_failure(message)
|
|
}
|
|
|
|
/// A failure that never became an HTTP response.
|
|
///
|
|
/// `api_call_with_retry` reports a missing token as plain text, so the
|
|
/// signed-out case is recognised here rather than surfacing as "something went
|
|
/// wrong" — being signed out is a state the user can fix.
|
|
pub fn transport_failure(message: &str) -> BackendFailure {
|
|
let code = if message.contains("Not logged in") || message.contains("No refresh token") {
|
|
NOT_SIGNED_IN
|
|
} else {
|
|
UNREACHABLE
|
|
};
|
|
BackendFailure {
|
|
status: 0,
|
|
code: code.to_string(),
|
|
params: BTreeMap::new(),
|
|
}
|
|
}
|
|
|
|
fn code_for_status(status: u16, codes: FailureCodes) -> &'static str {
|
|
match status {
|
|
400 | 422 => codes.bad_request,
|
|
401 => NOT_SIGNED_IN,
|
|
402 | 403 => codes.forbidden,
|
|
404 => codes.not_found,
|
|
409 => codes.conflict,
|
|
429 => RATE_LIMITED,
|
|
503 => NO_CAPACITY,
|
|
_ => UNAVAILABLE,
|
|
}
|
|
}
|
|
|
|
/// Read the backend's own `{"code":…}` envelope when it sent one.
|
|
fn from_body(status: u16, body: &str) -> Option<BackendFailure> {
|
|
let parsed = serde_json::from_str::<Value>(body).ok()?;
|
|
let object = parsed.as_object()?;
|
|
let code = object.get("code")?.as_str()?;
|
|
if code.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let mut params = BTreeMap::new();
|
|
|
|
// The nested shape first, so a top-level key of the same name still wins.
|
|
//
|
|
// The cookie-bot routes send every interpolated value under `params`
|
|
// (`{"code":…,"params":{…}}`) while the remote-session routes spread theirs
|
|
// at the top level. Only the flat one was read, so
|
|
// COOKIE_BOT_INVALID_TIMEZONE rendered with an empty timezone name,
|
|
// COOKIE_BOT_SITE_LIMIT always showed the hardcoded fallback, and a team out
|
|
// of hours was told it had "used 0 of 0".
|
|
if let Some(Value::Object(nested)) = object.get("params") {
|
|
collect_scalars(nested, &mut params);
|
|
}
|
|
|
|
for (key, value) in object {
|
|
if key == "code" || key == "params" {
|
|
continue;
|
|
}
|
|
if let Value::Array(items) = value {
|
|
if key == "conflicts" {
|
|
collect_conflict_params(items, &mut params);
|
|
}
|
|
continue;
|
|
}
|
|
if let Some(text) = scalar(value) {
|
|
params.insert(key.clone(), text);
|
|
}
|
|
}
|
|
|
|
Some(BackendFailure {
|
|
status,
|
|
code: code.to_string(),
|
|
params,
|
|
})
|
|
}
|
|
|
|
/// A JSON value that can be substituted into a translated sentence.
|
|
///
|
|
/// An object or an array has no rendering, so it is dropped rather than
|
|
/// stringified into the user's face.
|
|
fn scalar(value: &Value) -> Option<String> {
|
|
match value {
|
|
Value::String(text) => Some(text.clone()),
|
|
Value::Number(number) => Some(number.to_string()),
|
|
Value::Bool(flag) => Some(flag.to_string()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn collect_scalars(object: &serde_json::Map<String, Value>, params: &mut BTreeMap<String, String>) {
|
|
for (key, value) in object {
|
|
if let Some(text) = scalar(value) {
|
|
params.insert(key.clone(), text);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Name the teammate whose enrolment blocks this one.
|
|
///
|
|
/// A schedule conflict is only actionable if the user learns WHO and WHEN, and
|
|
/// the list arrives as an array the generic scalar copy would drop. Only the
|
|
/// first entry is surfaced; the full list is in the response body for the UI.
|
|
fn collect_conflict_params(items: &[Value], params: &mut BTreeMap<String, String>) {
|
|
params.insert("conflict_count".to_string(), items.len().to_string());
|
|
let Some(first) = items.first().and_then(Value::as_object) else {
|
|
return;
|
|
};
|
|
if let Some(email) = first.get("email").and_then(Value::as_str) {
|
|
params.insert("email".to_string(), email.to_string());
|
|
}
|
|
if let Some(timezone) = first.get("timezone").and_then(Value::as_str) {
|
|
params.insert("timezone".to_string(), timezone.to_string());
|
|
}
|
|
if let Some(minute) = first.get("run_at_minute").and_then(Value::as_u64) {
|
|
params.insert("run_at_minute".to_string(), minute.to_string());
|
|
params.insert("time".to_string(), format_minute_of_day(minute));
|
|
}
|
|
}
|
|
|
|
/// Minute-of-day to a zero-padded 24h clock reading.
|
|
///
|
|
/// The value is a wall-clock offset in the conflicting enrolment's own
|
|
/// timezone, so there is no date and nothing to convert — only to render.
|
|
pub fn format_minute_of_day(minute: u64) -> String {
|
|
let minute = minute % 1440;
|
|
format!("{:02}:{:02}", minute / 60, minute % 60)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const CODES: FailureCodes = FailureCodes {
|
|
bad_request: "BAD",
|
|
forbidden: "FORBIDDEN",
|
|
not_found: "MISSING",
|
|
conflict: "CLASH",
|
|
};
|
|
|
|
#[test]
|
|
fn the_backends_own_code_wins_over_the_status_default() {
|
|
// The status table is a fallback for gateway pages. When infra names the
|
|
// failure, that name is the one the user's locale has a string for.
|
|
let failure = classify(403, r#"{"code":"COOKIE_BOT_NOT_ENTITLED"}"#, CODES);
|
|
assert_eq!(failure.code, "COOKIE_BOT_NOT_ENTITLED");
|
|
assert_eq!(failure.status, 403);
|
|
}
|
|
|
|
#[test]
|
|
fn a_body_without_a_code_falls_back_to_the_routes_own_meaning() {
|
|
// 404 means "no schedule" on one route and "no such run" on another;
|
|
// sharing one code would tell the user the wrong thing on one of them.
|
|
assert_eq!(classify(404, "Not Found", CODES).code, "MISSING");
|
|
assert_eq!(classify(409, "", CODES).code, "CLASH");
|
|
assert_eq!(classify(400, "<html>", CODES).code, "BAD");
|
|
}
|
|
|
|
#[test]
|
|
fn capacity_and_rate_limits_are_never_reported_as_a_fault() {
|
|
// 503 is "come back in a minute" — the fleet is four Windows hosts wide,
|
|
// so a busy fleet is normal and must not look like an outage.
|
|
assert_eq!(classify(503, "", CODES).code, NO_CAPACITY);
|
|
assert_eq!(classify(429, "", CODES).code, RATE_LIMITED);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unauthenticated_response_is_always_the_signed_out_code() {
|
|
// Never the route's forbidden code: "sign in" and "upgrade your plan" are
|
|
// different instructions and the user can only follow one of them.
|
|
assert_eq!(classify(401, "", CODES).code, NOT_SIGNED_IN);
|
|
assert_eq!(classify(402, "", CODES).code, "FORBIDDEN");
|
|
}
|
|
|
|
#[test]
|
|
fn scalar_body_fields_become_translation_params() {
|
|
let failure = classify(
|
|
403,
|
|
r#"{"code":"REMOTE_HOURS_EXHAUSTED","granted":200,"used":201.5,"pooled":true}"#,
|
|
CODES,
|
|
);
|
|
assert_eq!(
|
|
failure.params.get("granted").map(String::as_str),
|
|
Some("200")
|
|
);
|
|
assert_eq!(
|
|
failure.params.get("used").map(String::as_str),
|
|
Some("201.5")
|
|
);
|
|
assert_eq!(
|
|
failure.params.get("pooled").map(String::as_str),
|
|
Some("true")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nested_params_are_read_because_that_is_the_shape_cookie_bot_sends() {
|
|
// `body(code, params)` in cookie-bot.errors.ts returns `{code, params}`,
|
|
// which Nest serialises verbatim. Reading only the top level dropped every
|
|
// interpolated value: the timezone the user typed, the site limit, the
|
|
// hours a team had actually spent.
|
|
let failure = classify(
|
|
400,
|
|
r#"{"code":"COOKIE_BOT_INVALID_TIMEZONE","params":{"timezone":"Europe/Nowhere"}}"#,
|
|
CODES,
|
|
);
|
|
assert_eq!(failure.code, "COOKIE_BOT_INVALID_TIMEZONE");
|
|
assert_eq!(
|
|
failure.params.get("timezone").map(String::as_str),
|
|
Some("Europe/Nowhere")
|
|
);
|
|
|
|
let limit = classify(
|
|
400,
|
|
r#"{"code":"COOKIE_BOT_SITE_LIMIT","params":{"min":1,"max":40}}"#,
|
|
CODES,
|
|
);
|
|
assert_eq!(limit.params.get("min").map(String::as_str), Some("1"));
|
|
assert_eq!(limit.params.get("max").map(String::as_str), Some("40"));
|
|
|
|
let hours = classify(
|
|
403,
|
|
r#"{"code":"REMOTE_HOURS_EXHAUSTED","params":{"granted":200,"used":214.5}}"#,
|
|
CODES,
|
|
);
|
|
assert_eq!(hours.params.get("granted").map(String::as_str), Some("200"));
|
|
assert_eq!(hours.params.get("used").map(String::as_str), Some("214.5"));
|
|
}
|
|
|
|
#[test]
|
|
fn both_body_shapes_coexist_and_the_top_level_one_wins() {
|
|
// The two planes disagree about where params live, and neither is going to
|
|
// change for the other. A key present in both must resolve once.
|
|
let failure = classify(
|
|
403,
|
|
r#"{"code":"REMOTE_HOURS_EXHAUSTED","granted":200,"params":{"granted":1,"used":5}}"#,
|
|
CODES,
|
|
);
|
|
assert_eq!(
|
|
failure.params.get("granted").map(String::as_str),
|
|
Some("200")
|
|
);
|
|
assert_eq!(failure.params.get("used").map(String::as_str), Some("5"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_non_scalar_param_is_dropped_rather_than_rendered_as_json() {
|
|
// These values are substituted into a translated sentence. An object has
|
|
// no rendering, and `[object Object]` in a toast is worse than nothing.
|
|
let failure = classify(
|
|
400,
|
|
r#"{"code":"COOKIE_BOT_INVALID_SCHEDULE","params":{"field":"sites","detail":{"a":1},"list":[1,2]}}"#,
|
|
CODES,
|
|
);
|
|
assert_eq!(
|
|
failure.params.get("field").map(String::as_str),
|
|
Some("sites")
|
|
);
|
|
assert!(!failure.params.contains_key("detail"));
|
|
assert!(!failure.params.contains_key("list"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_schedule_conflict_names_the_teammate_and_the_time() {
|
|
// Without these the dialog can only say "someone else already warms this
|
|
// profile", which is not something the user can act on.
|
|
let failure = classify(
|
|
409,
|
|
r#"{"code":"COOKIE_BOT_SCHEDULE_CONFLICT","conflicts":[{"email":"alex@example.com","run_at_minute":120,"timezone":"Europe/Berlin"}]}"#,
|
|
CODES,
|
|
);
|
|
assert_eq!(
|
|
failure.params.get("email").map(String::as_str),
|
|
Some("alex@example.com")
|
|
);
|
|
assert_eq!(
|
|
failure.params.get("time").map(String::as_str),
|
|
Some("02:00")
|
|
);
|
|
assert_eq!(
|
|
failure.params.get("conflict_count").map(String::as_str),
|
|
Some("1")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn minute_of_day_renders_as_a_padded_clock_reading() {
|
|
assert_eq!(format_minute_of_day(0), "00:00");
|
|
assert_eq!(format_minute_of_day(9 * 60 + 5), "09:05");
|
|
assert_eq!(format_minute_of_day(1439), "23:59");
|
|
}
|
|
|
|
#[test]
|
|
fn a_status_encoded_message_round_trips_to_its_code() {
|
|
assert_eq!(
|
|
classify_message(r#"(409) {"code":"COOKIE_BOT_RUN_IN_PROGRESS"}"#, CODES).code,
|
|
"COOKIE_BOT_RUN_IN_PROGRESS"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_signed_out_desktop_is_told_to_sign_in_not_that_the_network_failed() {
|
|
assert_eq!(classify_message("Not logged in", CODES).code, NOT_SIGNED_IN);
|
|
assert_eq!(
|
|
classify_message("reach backend: connection refused", CODES).code,
|
|
UNREACHABLE
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_rendered_json_is_what_translate_backend_error_parses() {
|
|
let failure = classify(404, r#"{"code":"COOKIE_BOT_NOT_ENROLLED"}"#, CODES);
|
|
assert_eq!(
|
|
failure.to_error_json(),
|
|
r#"{"code":"COOKIE_BOT_NOT_ENROLLED"}"#
|
|
);
|
|
|
|
let with_params = classify(
|
|
403,
|
|
r#"{"code":"REMOTE_HOURS_EXHAUSTED","granted":200}"#,
|
|
CODES,
|
|
);
|
|
let parsed: Value = serde_json::from_str(&with_params.to_error_json())
|
|
.expect("the rendered error must be valid JSON");
|
|
assert_eq!(parsed["code"], "REMOTE_HOURS_EXHAUSTED");
|
|
assert_eq!(parsed["params"]["granted"], "200");
|
|
}
|
|
|
|
#[test]
|
|
fn split_status_does_not_misread_ordinary_prose() {
|
|
assert_eq!(split_status("(503) busy"), Some((503, "busy")));
|
|
assert_eq!(split_status("(nope) busy"), None);
|
|
assert_eq!(split_status("decode response: expected value"), None);
|
|
}
|
|
}
|