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
13 changes: 11 additions & 2 deletions rust/cube-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,17 @@ pins a specific release tag).

Every run checks GitHub for a newer release in the background and prints a
notice when one is available (set `CUBE_NO_UPDATE_CHECK=1` to disable, e.g.
in CI; the notice only goes to interactive terminals, on stderr). Update
in place any time with:
in CI; the notice only goes to interactive terminals, on stderr).

The same check feeds a hint under API errors: when a request fails on the API
side *and* this binary is behind, the error is followed by a line suggesting
`cube update`, since a CLI that lags the API is a common cause of otherwise
puzzling failures. A CLI already on the latest release is never told to
update, and `CUBE_NO_UPDATE_CHECK=1` silences the hint along with the notice.
Unlike the notice, the hint is not limited to interactive terminals — it is
attached to a failure, and a stale pinned CLI in CI is where it pays off.

Update in place any time with:

```bash
cube update # download the latest release and replace this binary
Expand Down
65 changes: 60 additions & 5 deletions rust/cube-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use anyhow::{anyhow, bail, Result};
use reqwest::{Method, StatusCode};
use serde_json::Value;

use crate::error::api_bail;
use crate::oauth;

/// Thin HTTP client over the Cube Cloud public REST API.
Expand Down Expand Up @@ -196,12 +197,12 @@ impl Client {
})
.unwrap_or_else(|| text.clone());
match status {
StatusCode::UNAUTHORIZED => bail!(
StatusCode::UNAUTHORIZED => api_bail!(
"unauthorized (401): session expired — run `cube login` (or set CUBE_API_KEY). {detail}"
),
StatusCode::FORBIDDEN => bail!("forbidden (403): {detail}"),
StatusCode::NOT_FOUND => bail!("not found (404): {method} {path}. {detail}"),
_ => bail!("{method} {path} failed with {status}: {detail}"),
StatusCode::FORBIDDEN => api_bail!("forbidden (403): {detail}"),
StatusCode::NOT_FOUND => api_bail!("not found (404): {method} {path}. {detail}"),
_ => api_bail!("{method} {path} failed with {status}: {detail}"),
}
}

Expand All @@ -215,7 +216,7 @@ impl Client {
let looks_like_html =
trimmed.len() >= 2 && trimmed.starts_with('<') && !trimmed.starts_with("<?xml");
if looks_like_html {
bail!(
api_bail!(
"{method} {path} returned the Cube Cloud web app instead of JSON — \
this endpoint is not available on this tenant (the server may be \
running an older version)"
Expand Down Expand Up @@ -289,3 +290,57 @@ fn persist(context_name: &str, access_token: &str, refresh_token: Option<&str>)
let _ = config.save();
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::error::is_api_error;

fn client() -> Client {
Client::new("https://example.cubecloud.dev", "sk-test").unwrap()
}

fn finish(status: StatusCode, body: &str) -> Result<Value> {
client().finish_response(
&Method::GET,
"/api/v1/deployments",
status,
body.to_string(),
)
}

#[test]
fn unsuccessful_status_is_an_api_error() {
let err = finish(StatusCode::BAD_REQUEST, r#"{"message":"unknown field"}"#).unwrap_err();
assert!(is_api_error(&err));
assert!(err.to_string().contains("unknown field"), "{err}");
}

#[test]
fn every_mapped_status_is_an_api_error() {
for status in [
StatusCode::UNAUTHORIZED,
StatusCode::FORBIDDEN,
StatusCode::NOT_FOUND,
StatusCode::INTERNAL_SERVER_ERROR,
] {
let err = finish(status, "").unwrap_err();
assert!(is_api_error(&err), "{status} should be an API error");
}
}

#[test]
fn web_app_html_instead_of_json_is_an_api_error() {
let err = finish(StatusCode::OK, "<!doctype html><html></html>").unwrap_err();
assert!(is_api_error(&err));
}

#[test]
fn successful_responses_still_parse() {
assert_eq!(
finish(StatusCode::OK, r#"{"items":[]}"#).unwrap(),
serde_json::json!({"items": []})
);
assert_eq!(finish(StatusCode::NO_CONTENT, "").unwrap(), Value::Null);
}
}
72 changes: 72 additions & 0 deletions rust/cube-cli/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/// An error that came from an API response — an unsuccessful status, or a
/// body the CLI could not make sense of — as opposed to a local failure or a
/// transport error (DNS, TLS, connection refused).
///
/// It is a distinct error type so `main` can recognize it in the error chain
/// and suggest an update: skew between an older CLI and a newer API is a
/// common cause of otherwise puzzling API errors.
#[derive(Debug)]
pub struct ApiError(String);

impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}

impl std::error::Error for ApiError {}

/// Build an [`ApiError`] as an `anyhow::Error`, ready to return or wrap in
/// further context.
pub fn api_error(message: impl Into<String>) -> anyhow::Error {
anyhow::Error::new(ApiError(message.into()))
}

/// `bail!` for API failures: returns an [`ApiError`] instead of a plain one.
macro_rules! api_bail {
($($arg:tt)*) => {
return Err($crate::error::api_error(format!($($arg)*)))
};
}

pub(crate) use api_bail;

/// True when `err`, or anything it wraps, came from an API response.
pub fn is_api_error(err: &anyhow::Error) -> bool {
err.chain().any(|e| e.is::<ApiError>())
}

#[cfg(test)]
mod tests {
use super::*;
use anyhow::{anyhow, Context as _};

#[test]
fn recognizes_api_errors() {
assert!(is_api_error(&api_error("GET /x failed with 400")));
}

#[test]
fn recognizes_api_errors_wrapped_in_context() {
let err = Err::<(), _>(api_error("GET /x failed with 400"))
.context("while listing deployments")
.unwrap_err();
assert!(is_api_error(&err));
}

#[test]
fn ignores_other_errors() {
assert!(!is_api_error(&anyhow!("no config file found")));
assert!(!is_api_error(
&anyhow!("connection refused").context("request to https://x failed")
));
}

#[test]
fn displays_the_message_it_was_built_with() {
assert_eq!(
api_error("GET /x failed with 400").to_string(),
"GET /x failed with 400"
);
}
}
21 changes: 17 additions & 4 deletions rust/cube-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod client;
mod commands;
mod config;
mod error;
mod oauth;
mod output;
mod telemetry;
Expand Down Expand Up @@ -281,6 +282,9 @@ async fn main() {
let command_name = command.name();

let result = run(global, command).await;
// API failures are commonly caused by a CLI that lags the API, so they
// get an extra "try updating" hint below the error.
let api_error = result.as_ref().err().is_some_and(error::is_api_error);

if !is_completion {
let mut props = serde_json::Map::new();
Expand All @@ -295,11 +299,20 @@ async fn main() {
}
telemetry::flush().await;
}
if let Some(check) = check {
update::print_notice(check).await;
}
if let Err(err) = result {
// Print the failure before consulting the release check: only the hint
// needs that answer, and a slow or unreachable GitHub must not sit between
// the user and the error they are waiting for.
if let Err(err) = &result {
eprintln!("error: {err:#}");
}
let outcome = update::resolve(check, api_error).await;
let announced = update::print_notice(&outcome);
if result.is_err() {
if api_error {
if let Some(hint) = update::api_error_hint(&outcome, announced) {
eprintln!("{hint}");
}
}
std::process::exit(1);
}
}
Expand Down
21 changes: 13 additions & 8 deletions rust/cube-cli/src/oauth.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
use std::time::{Duration, Instant};

use anyhow::{anyhow, bail, Result};
use anyhow::{bail, Result};
use serde::Deserialize;

use crate::error::{api_bail, api_error};

/// OAuth 2.0 Device Authorization Grant (RFC 8628).
///
/// Cube Cloud exposes a confidential authorization-code + device server under
Expand Down Expand Up @@ -99,13 +101,16 @@ pub async fn request_device_code(
let status = res.status();
let text = res.text().await.unwrap_or_default();
if !status.is_success() {
bail!(
api_bail!(
"device authorization request failed ({status}) at {endpoint}: {}",
text.trim()
);
}
serde_json::from_str(&text)
.map_err(|e| anyhow!("could not parse device authorization response: {e}\n{text}"))
serde_json::from_str(&text).map_err(|e| {
api_error(format!(
"could not parse device authorization response: {e}\n{text}"
))
})
}

/// Step 3 — poll the token endpoint until the user approves (or it fails).
Expand Down Expand Up @@ -144,7 +149,7 @@ pub async fn poll_for_token(

if status.is_success() {
return serde_json::from_str(&text)
.map_err(|e| anyhow!("could not parse token response: {e}\n{text}"));
.map_err(|e| api_error(format!("could not parse token response: {e}\n{text}")));
}

// RFC 8628 §3.5: pending/slow_down keep polling; anything else is fatal.
Expand All @@ -166,7 +171,7 @@ pub async fn poll_for_token(
.unwrap_or_default()
),
},
Err(_) => bail!(
Err(_) => api_bail!(
"token poll failed ({status}) at {endpoint}: {}",
text.trim()
),
Expand Down Expand Up @@ -196,10 +201,10 @@ pub async fn refresh(
let status = res.status();
let text = res.text().await.unwrap_or_default();
if !status.is_success() {
bail!("token refresh failed ({status}): {}", text.trim());
api_bail!("token refresh failed ({status}): {}", text.trim());
}
serde_json::from_str(&text)
.map_err(|e| anyhow!("could not parse refresh response: {e}\n{text}"))
.map_err(|e| api_error(format!("could not parse refresh response: {e}\n{text}")))
}

/// Best-effort attempt to open a URL in the user's browser (no extra deps).
Expand Down
Loading
Loading