atlassian_cli_api/
error.rs1use thiserror::Error;
2
3#[derive(Error, Debug)]
4pub enum ApiError {
5 #[error("HTTP request failed: {0}")]
6 RequestFailed(#[from] reqwest::Error),
7
8 #[error("Rate limit exceeded. Retry after {retry_after} seconds")]
9 RateLimitExceeded { retry_after: u64 },
10
11 #[error("Authentication failed: {message}")]
12 AuthenticationFailed { message: String },
13
14 #[error("Access forbidden: {message}")]
15 Forbidden { message: String },
16
17 #[error("Resource not found: {resource}")]
18 NotFound { resource: String },
19
20 #[error("Invalid request: {message}")]
21 BadRequest { message: String },
22
23 #[error("Server error: {status} - {message}")]
24 ServerError { status: u16, message: String },
25
26 #[error("Invalid URL: {0}")]
27 InvalidUrl(#[from] url::ParseError),
28
29 #[error("JSON serialization error: {0}")]
30 JsonError(#[from] serde_json::Error),
31
32 #[error("Request timeout after {attempts} attempts")]
33 Timeout { attempts: usize },
34
35 #[error("API endpoint removed: {message}")]
36 EndpointGone { message: String },
37
38 #[error("Invalid response format: {0}")]
39 InvalidResponse(String),
40}
41
42impl ApiError {
43 pub fn is_retryable(&self) -> bool {
44 match self {
45 ApiError::RateLimitExceeded { .. } => true,
46 ApiError::ServerError { status, .. } if *status >= 500 => true,
47 ApiError::Timeout { .. } => true,
48 ApiError::EndpointGone { .. } => false,
49 _ => false,
50 }
51 }
52
53 pub fn suggestion(&self) -> Option<String> {
54 match self {
55 ApiError::AuthenticationFailed { message } => {
56 let base = "Verify tokens with: atlassian-cli auth list\nTest auth with: atlassian-cli auth test [--bitbucket]".to_string();
57 if message.to_lowercase().contains("scope") {
60 Some(format!(
61 "{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"
62 ))
63 } else {
64 Some(base)
65 }
66 }
67 ApiError::Forbidden { message } => {
68 let base = "Verify tokens with: atlassian-cli auth list\nTest auth with: atlassian-cli auth test [--bitbucket]".to_string();
69 if let Some(hint) = scope_hint(message) {
70 return Some(hint);
71 }
72 let lower = message.to_lowercase();
73 if lower.contains("scope") || lower.contains("privilege") || lower.contains("permission") {
74 Some(format!(
75 "{base}\nIf this is a scope problem, note that a token's scopes are fixed when it is created: \
76 make a replacement at https://id.atlassian.com/manage-profile/security/api-tokens"
77 ))
78 } else {
79 Some(base)
80 }
81 }
82 ApiError::RateLimitExceeded { .. } => {
83 Some("Consider reducing request frequency or use bulk operations".to_string())
84 }
85 ApiError::NotFound { .. } => Some("Check if the resource ID is correct".to_string()),
86 ApiError::BadRequest { message } => {
87 if message.contains("Version number must be 1") {
88 Some("This is a draft page. Use 'confluence page publish' to publish for the first time".to_string())
89 } else if message.to_lowercase().contains("version") {
90 Some("Version conflict detected. The content may have been modified. Fetch latest and retry".to_string())
91 } else {
92 Some("Review the request parameters".to_string())
93 }
94 }
95 ApiError::Timeout { .. } => Some("Check your network connection or try again later".to_string()),
96 ApiError::EndpointGone { .. } => {
97 Some("This API endpoint has been removed by Atlassian. Update atlassian-cli to the latest version.".to_string())
98 }
99 _ => None,
100 }
101 }
102}
103
104pub type Result<T> = std::result::Result<T, ApiError>;
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 #[test]
111 fn forbidden_has_suggestion() {
112 let err = ApiError::Forbidden {
113 message: "no access".to_string(),
114 };
115 assert!(err.suggestion().is_some());
116 assert!(err.suggestion().unwrap().contains("auth test"));
117 }
118
119 #[test]
120 fn forbidden_is_not_retryable() {
121 let err = ApiError::Forbidden {
122 message: "no access".to_string(),
123 };
124 assert!(!err.is_retryable());
125 }
126
127 #[test]
128 fn authentication_failed_has_suggestion() {
129 let err = ApiError::AuthenticationFailed {
130 message: "expired".to_string(),
131 };
132 assert!(err.suggestion().is_some());
133 assert!(err.suggestion().unwrap().contains("auth test"));
134 }
135
136 #[test]
140 fn forbidden_with_scope_message_points_at_a_replacement_token() {
141 let err = ApiError::Forbidden {
142 message: "Your credentials lack the required scope.".to_string(),
143 };
144 let hint = err.suggestion().unwrap();
145 assert!(hint.contains("auth test"));
146 assert!(hint.contains("id.atlassian.com"), "{hint}");
147 assert!(hint.contains("replacement"), "{hint}");
148 assert!(!hint.contains("app-passwords"), "{hint}");
149 }
150
151 #[test]
152 fn forbidden_without_scope_omits_the_token_advice() {
153 let err = ApiError::Forbidden {
154 message: "no access".to_string(),
155 };
156 let hint = err.suggestion().unwrap();
157 assert!(hint.contains("auth test"));
158 assert!(!hint.contains("replacement"), "{hint}");
159 }
160
161 #[test]
162 fn forbidden_with_permission_message_includes_token_guidance() {
163 let err = ApiError::Forbidden {
164 message: "Insufficient Permission to access this resource".to_string(),
165 };
166 let hint = err.suggestion().unwrap();
167 assert!(hint.contains("id.atlassian.com"), "{hint}");
168 }
169
170 #[test]
173 fn a_structured_403_gets_the_specific_hint() {
174 let err = ApiError::Forbidden {
175 message: serde_json::json!({
176 "error": {"detail": {"granted": ["account"], "required": ["pullrequest"]}}
177 })
178 .to_string(),
179 };
180 let hint = err.suggestion().unwrap();
181 assert!(hint.contains("missing the pullrequest scope"), "{hint}");
182 assert!(
183 !hint.contains("auth test"),
184 "specific hint replaces the generic: {hint}"
185 );
186 }
187
188 #[test]
189 fn authentication_failed_with_scope_message_points_at_scopes() {
190 let err = ApiError::AuthenticationFailed {
191 message: "Invalid or expired credentials (Unauthorized; scope does not match)"
192 .to_string(),
193 };
194 let hint = err.suggestion().unwrap();
195 assert!(hint.contains("missing scope"));
196 assert!(hint.contains("api-tokens"));
197 }
198
199 #[test]
200 fn authentication_failed_without_scope_message_omits_scope_hint() {
201 let err = ApiError::AuthenticationFailed {
202 message: "Invalid or expired credentials".to_string(),
203 };
204 let hint = err.suggestion().unwrap();
205 assert!(hint.contains("auth test"));
206 assert!(!hint.contains("missing scope"));
207 }
208
209 #[test]
210 fn endpoint_gone_has_suggestion() {
211 let err = ApiError::EndpointGone {
212 message: "The requested API has been removed".to_string(),
213 };
214 assert!(err.suggestion().is_some());
215 assert!(err.suggestion().unwrap().contains("Update atlassian-cli"));
216 }
217
218 #[test]
219 fn endpoint_gone_is_not_retryable() {
220 let err = ApiError::EndpointGone {
221 message: "removed".to_string(),
222 };
223 assert!(!err.is_retryable());
224 }
225}
226
227fn scope_hint(message: &str) -> Option<String> {
245 let parsed: serde_json::Value = serde_json::from_str(message).ok()?;
246 let detail = parsed.get("error")?.get("detail")?;
247
248 let list = |key: &str| -> Vec<String> {
249 detail
250 .get(key)
251 .and_then(|v| v.as_array())
252 .map(|items| {
253 items
254 .iter()
255 .filter_map(|i| i.as_str().map(str::to_string))
256 .collect()
257 })
258 .unwrap_or_default()
259 };
260
261 let granted = list("granted");
262 let required = list("required");
263 if required.is_empty() {
264 return None;
265 }
266
267 let missing: Vec<&String> = required.iter().filter(|r| !granted.contains(r)).collect();
268
269 if missing.is_empty() {
270 return Some(format!(
271 "The token already grants every scope this endpoint requires ({}).\n\
272 The refusal is therefore not about scopes: check the account's access to this \
273 resource, and whether an IP allowlist applies.",
274 required.join(", ")
275 ));
276 }
277
278 let missing: Vec<&str> = missing.iter().map(|s| s.as_str()).collect();
279 Some(format!(
280 "The token is missing the {} scope{}.\n\
281 Granted: {}\n\
282 A token's scopes are fixed when it is created and cannot be widened, so create a \
283 replacement at https://id.atlassian.com/manage-profile/security/api-tokens \
284 and re-run `atlassian-cli auth login --bitbucket`.",
285 missing.join(", "),
286 if missing.len() == 1 { "" } else { "s" },
287 if granted.is_empty() {
288 "(none reported)".to_string()
289 } else {
290 granted.join(", ")
291 }
292 ))
293}
294
295#[cfg(test)]
296mod scope_hint_tests {
297 use super::*;
298
299 fn body(granted: &[&str], required: &[&str]) -> String {
300 serde_json::json!({
301 "type": "error",
302 "error": {
303 "message": "Your credentials lack one or more required privilege scopes.",
304 "detail": {"granted": granted, "required": required}
305 }
306 })
307 .to_string()
308 }
309
310 #[test]
311 fn it_names_the_missing_scope() {
312 let hint = scope_hint(&body(&["repository:write"], &["pullrequest"])).unwrap();
313 assert!(hint.contains("missing the pullrequest scope"), "{hint}");
314 assert!(hint.contains("repository:write"), "granted list: {hint}");
315 }
316
317 #[test]
320 fn it_tells_the_user_to_replace_the_token_not_edit_it() {
321 let hint = scope_hint(&body(&[], &["write:pipeline:bitbucket"])).unwrap();
322 assert!(hint.contains("create a replacement"), "{hint}");
323 assert!(!hint.to_lowercase().contains("add the scope"), "{hint}");
324 }
325
326 #[test]
329 fn a_sufficient_token_points_away_from_scopes() {
330 let hint = scope_hint(&body(&["pullrequest", "account"], &["pullrequest"])).unwrap();
331 assert!(hint.contains("not about scopes"), "{hint}");
332 assert!(hint.contains("IP allowlist"), "{hint}");
333 }
334
335 #[test]
336 fn it_handles_both_scope_vocabularies() {
337 assert!(scope_hint(&body(&["repository"], &["pullrequest"])).is_some());
339 assert!(scope_hint(&body(
340 &["read:repository:bitbucket"],
341 &["write:pipeline:bitbucket"]
342 ))
343 .is_some());
344 }
345
346 #[test]
347 fn a_body_without_the_detail_block_yields_nothing() {
348 assert!(scope_hint("Access forbidden").is_none());
349 assert!(scope_hint(r#"{"error":{"message":"nope"}}"#).is_none());
350 }
351}