Skip to main content

atlassian_cli_api/
error.rs

1use 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                // A scope mismatch is not a bad token: re-issuing the same
58                // token changes nothing, so point at the scopes instead.
59                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    /// The hint used to point at bitbucket.org/account/settings/app-passwords.
137    /// App passwords are deprecated, and scopes cannot be edited after a token
138    /// is created, so that link sent people somewhere that could not help.
139    #[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    /// When the body carries the granted/required detail, the precise hint
171    /// wins over the keyword-matched generic one.
172    #[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
227/// Turn Bitbucket's 403 body into a message that names the missing scope.
228///
229/// Bitbucket says which scopes the credential has and which the endpoint
230/// wanted, and the CLI was throwing that away:
231///
232/// ```json
233/// {"type": "error", "error": {"message": "Your credentials lack one or more required privilege scopes.",
234///  "detail": {"granted": ["repository:write"], "required": ["pullrequest"]}}}
235/// ```
236///
237/// Two details matter for what the hint should say. Scopes are fixed when a
238/// token is created and cannot be widened afterwards, so telling someone to
239/// "add the scope" sends them somewhere that cannot help; they need a
240/// replacement token. And `granted` describes the *token*, not the account --
241/// so when it already covers everything required, the cause is repository
242/// permissions or an IP allowlist, and saying "missing scope" would send them
243/// down the wrong path entirely.
244fn 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    /// Scopes cannot be widened after creation, so "add the scope" would send
318    /// the user somewhere that cannot help them.
319    #[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    /// `granted` describes the token, not the account. When it already covers
327    /// what was required, blaming scopes sends the user down the wrong path.
328    #[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        // Classic OAuth consumer scopes, and the newer scoped API-token form.
338        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}