use thiserror::Error;
#[derive(Error, Debug)]
pub enum ApiError {
#[error("HTTP request failed: {0}")]
RequestFailed(#[from] reqwest::Error),
#[error("Rate limit exceeded. Retry after {retry_after} seconds")]
RateLimitExceeded { retry_after: u64 },
#[error("Authentication failed: {message}")]
AuthenticationFailed { message: String },
#[error("Access forbidden: {message}")]
Forbidden { message: String },
#[error("Resource not found: {resource}")]
NotFound { resource: String },
#[error("Invalid request: {message}")]
BadRequest { message: String },
#[error("Server error: {status} - {message}")]
ServerError { status: u16, message: String },
#[error("Invalid URL: {0}")]
InvalidUrl(#[from] url::ParseError),
#[error("JSON serialization error: {0}")]
JsonError(#[from] serde_json::Error),
#[error("Request timeout after {attempts} attempts")]
Timeout { attempts: usize },
#[error("API endpoint removed: {message}")]
EndpointGone { message: String },
#[error("Invalid response format: {0}")]
InvalidResponse(String),
}
impl ApiError {
pub fn is_retryable(&self) -> bool {
match self {
ApiError::RateLimitExceeded { .. } => true,
ApiError::ServerError { status, .. } if *status >= 500 => true,
ApiError::Timeout { .. } => true,
ApiError::EndpointGone { .. } => false,
_ => false,
}
}
pub fn suggestion(&self) -> Option<String> {
match self {
ApiError::AuthenticationFailed { message } => {
let base = "Verify tokens with: atlassian-cli auth list\nTest auth with: atlassian-cli auth test [--bitbucket]".to_string();
if message.to_lowercase().contains("scope") {
Some(format!(
"{base}\nThis looks like a missing scope, not a bad token. Re-create the token with the scopes the command needs at:\nhttps://id.atlassian.com/manage-profile/security/api-tokens"
))
} else {
Some(base)
}
}
ApiError::Forbidden { message } => {
let base = "Verify tokens with: atlassian-cli auth list\nTest auth with: atlassian-cli auth test [--bitbucket]".to_string();
if let Some(hint) = scope_hint(message) {
return Some(hint);
}
let lower = message.to_lowercase();
if lower.contains("scope") || lower.contains("privilege") || lower.contains("permission") {
Some(format!(
"{base}\nIf this is a scope problem, note that a token's scopes are fixed when it is created: \
make a replacement at https://id.atlassian.com/manage-profile/security/api-tokens"
))
} else {
Some(base)
}
}
ApiError::RateLimitExceeded { .. } => {
Some("Consider reducing request frequency or use bulk operations".to_string())
}
ApiError::NotFound { .. } => Some("Check if the resource ID is correct".to_string()),
ApiError::BadRequest { message } => {
if message.contains("Version number must be 1") {
Some("This is a draft page. Use 'confluence page publish' to publish for the first time".to_string())
} else if message.to_lowercase().contains("version") {
Some("Version conflict detected. The content may have been modified. Fetch latest and retry".to_string())
} else {
Some("Review the request parameters".to_string())
}
}
ApiError::Timeout { .. } => Some("Check your network connection or try again later".to_string()),
ApiError::EndpointGone { .. } => {
Some("This API endpoint has been removed by Atlassian. Update atlassian-cli to the latest version.".to_string())
}
_ => None,
}
}
}
pub type Result<T> = std::result::Result<T, ApiError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn forbidden_has_suggestion() {
let err = ApiError::Forbidden {
message: "no access".to_string(),
};
assert!(err.suggestion().is_some());
assert!(err.suggestion().unwrap().contains("auth test"));
}
#[test]
fn forbidden_is_not_retryable() {
let err = ApiError::Forbidden {
message: "no access".to_string(),
};
assert!(!err.is_retryable());
}
#[test]
fn authentication_failed_has_suggestion() {
let err = ApiError::AuthenticationFailed {
message: "expired".to_string(),
};
assert!(err.suggestion().is_some());
assert!(err.suggestion().unwrap().contains("auth test"));
}
#[test]
fn forbidden_with_scope_message_points_at_a_replacement_token() {
let err = ApiError::Forbidden {
message: "Your credentials lack the required scope.".to_string(),
};
let hint = err.suggestion().unwrap();
assert!(hint.contains("auth test"));
assert!(hint.contains("id.atlassian.com"), "{hint}");
assert!(hint.contains("replacement"), "{hint}");
assert!(!hint.contains("app-passwords"), "{hint}");
}
#[test]
fn forbidden_without_scope_omits_the_token_advice() {
let err = ApiError::Forbidden {
message: "no access".to_string(),
};
let hint = err.suggestion().unwrap();
assert!(hint.contains("auth test"));
assert!(!hint.contains("replacement"), "{hint}");
}
#[test]
fn forbidden_with_permission_message_includes_token_guidance() {
let err = ApiError::Forbidden {
message: "Insufficient Permission to access this resource".to_string(),
};
let hint = err.suggestion().unwrap();
assert!(hint.contains("id.atlassian.com"), "{hint}");
}
#[test]
fn a_structured_403_gets_the_specific_hint() {
let err = ApiError::Forbidden {
message: serde_json::json!({
"error": {"detail": {"granted": ["account"], "required": ["pullrequest"]}}
})
.to_string(),
};
let hint = err.suggestion().unwrap();
assert!(hint.contains("missing the pullrequest scope"), "{hint}");
assert!(
!hint.contains("auth test"),
"specific hint replaces the generic: {hint}"
);
}
#[test]
fn authentication_failed_with_scope_message_points_at_scopes() {
let err = ApiError::AuthenticationFailed {
message: "Invalid or expired credentials (Unauthorized; scope does not match)"
.to_string(),
};
let hint = err.suggestion().unwrap();
assert!(hint.contains("missing scope"));
assert!(hint.contains("api-tokens"));
}
#[test]
fn authentication_failed_without_scope_message_omits_scope_hint() {
let err = ApiError::AuthenticationFailed {
message: "Invalid or expired credentials".to_string(),
};
let hint = err.suggestion().unwrap();
assert!(hint.contains("auth test"));
assert!(!hint.contains("missing scope"));
}
#[test]
fn endpoint_gone_has_suggestion() {
let err = ApiError::EndpointGone {
message: "The requested API has been removed".to_string(),
};
assert!(err.suggestion().is_some());
assert!(err.suggestion().unwrap().contains("Update atlassian-cli"));
}
#[test]
fn endpoint_gone_is_not_retryable() {
let err = ApiError::EndpointGone {
message: "removed".to_string(),
};
assert!(!err.is_retryable());
}
}
fn scope_hint(message: &str) -> Option<String> {
let parsed: serde_json::Value = serde_json::from_str(message).ok()?;
let detail = parsed.get("error")?.get("detail")?;
let list = |key: &str| -> Vec<String> {
detail
.get(key)
.and_then(|v| v.as_array())
.map(|items| {
items
.iter()
.filter_map(|i| i.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
};
let granted = list("granted");
let required = list("required");
if required.is_empty() {
return None;
}
let missing: Vec<&String> = required.iter().filter(|r| !granted.contains(r)).collect();
if missing.is_empty() {
return Some(format!(
"The token already grants every scope this endpoint requires ({}).\n\
The refusal is therefore not about scopes: check the account's access to this \
resource, and whether an IP allowlist applies.",
required.join(", ")
));
}
let missing: Vec<&str> = missing.iter().map(|s| s.as_str()).collect();
Some(format!(
"The token is missing the {} scope{}.\n\
Granted: {}\n\
A token's scopes are fixed when it is created and cannot be widened, so create a \
replacement at https://id.atlassian.com/manage-profile/security/api-tokens \
and re-run `atlassian-cli auth login --bitbucket`.",
missing.join(", "),
if missing.len() == 1 { "" } else { "s" },
if granted.is_empty() {
"(none reported)".to_string()
} else {
granted.join(", ")
}
))
}
#[cfg(test)]
mod scope_hint_tests {
use super::*;
fn body(granted: &[&str], required: &[&str]) -> String {
serde_json::json!({
"type": "error",
"error": {
"message": "Your credentials lack one or more required privilege scopes.",
"detail": {"granted": granted, "required": required}
}
})
.to_string()
}
#[test]
fn it_names_the_missing_scope() {
let hint = scope_hint(&body(&["repository:write"], &["pullrequest"])).unwrap();
assert!(hint.contains("missing the pullrequest scope"), "{hint}");
assert!(hint.contains("repository:write"), "granted list: {hint}");
}
#[test]
fn it_tells_the_user_to_replace_the_token_not_edit_it() {
let hint = scope_hint(&body(&[], &["write:pipeline:bitbucket"])).unwrap();
assert!(hint.contains("create a replacement"), "{hint}");
assert!(!hint.to_lowercase().contains("add the scope"), "{hint}");
}
#[test]
fn a_sufficient_token_points_away_from_scopes() {
let hint = scope_hint(&body(&["pullrequest", "account"], &["pullrequest"])).unwrap();
assert!(hint.contains("not about scopes"), "{hint}");
assert!(hint.contains("IP allowlist"), "{hint}");
}
#[test]
fn it_handles_both_scope_vocabularies() {
assert!(scope_hint(&body(&["repository"], &["pullrequest"])).is_some());
assert!(scope_hint(&body(
&["read:repository:bitbucket"],
&["write:pipeline:bitbucket"]
))
.is_some());
}
#[test]
fn a_body_without_the_detail_block_yields_nothing() {
assert!(scope_hint("Access forbidden").is_none());
assert!(scope_hint(r#"{"error":{"message":"nope"}}"#).is_none());
}
}