Skip to content

Commit 8a5d269

Browse files
hojinyooclaude
andcommitted
fix(cli): map GraphQL-payload errors to precise exit codes
Linear returns application-level failures (not-found, auth, rate-limit) as HTTP 200 with a GraphQL `errors` array, so the transport-level status mapping in `http_error` never saw them and every such failure collapsed to exit code 1. The most common case — `i get <bad-id>` — returned 1 instead of the documented 2 (not found), defeating agents that branch on exit codes. Classify the first GraphQL error via its `extensions.statusCode` / `extensions.code` / message text into NotFound (2) / Auth (3) / RateLimited (4), falling back to General (1). Note "Entity not found" arrives with statusCode 400 (not 404), so message-text is the fallback that catches it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a90edd9 commit 8a5d269

2 files changed

Lines changed: 134 additions & 3 deletions

File tree

‎src/api.rs‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -695,9 +695,7 @@ impl LinearClient {
695695
let result: Value = response.json().await?;
696696

697697
if let Some(errors) = result.get("errors") {
698-
return Err(CliError::general("GraphQL error")
699-
.with_details(errors.clone())
700-
.into());
698+
return Err(CliError::from_graphql_errors(errors).into());
701699
}
702700

703701
Ok(result)

‎src/error.rs‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,71 @@ impl CliError {
7272
self.retry_after = retry_after;
7373
self
7474
}
75+
76+
/// Classify a GraphQL `errors` array into a `CliError` with a precise exit code.
77+
///
78+
/// Linear reports application-level failures as HTTP 200 with an `errors`
79+
/// array, so the real signal lives in the first error's `extensions`
80+
/// (`statusCode` / `code`) or its message text rather than the transport
81+
/// status. The message stays "GraphQL error" so `Display` still appends the
82+
/// underlying error messages.
83+
pub fn from_graphql_errors(errors: &Value) -> Self {
84+
let first = errors.as_array().and_then(|arr| arr.first());
85+
let kind = first.map_or(ErrorKind::General, Self::classify_graphql_error);
86+
let mut err = Self::new(kind, "GraphQL error").with_details(errors.clone());
87+
if kind == ErrorKind::RateLimited {
88+
let retry_after = first
89+
.and_then(|e| e.get("extensions"))
90+
.and_then(|ext| ext.get("retryAfter"))
91+
.and_then(Value::as_u64);
92+
err = err.with_retry_after(retry_after);
93+
}
94+
err
95+
}
96+
97+
fn classify_graphql_error(error: &Value) -> ErrorKind {
98+
let ext = error.get("extensions");
99+
100+
// 1. Numeric status code carried in extensions, when present.
101+
if let Some(status) = ext.and_then(|e| e.get("statusCode")).and_then(Value::as_u64) {
102+
match status {
103+
401 | 403 => return ErrorKind::Auth,
104+
404 => return ErrorKind::NotFound,
105+
429 => return ErrorKind::RateLimited,
106+
_ => {}
107+
}
108+
}
109+
110+
// 2. Symbolic code string (e.g. AUTHENTICATION_ERROR, RATELIMITED).
111+
if let Some(code) = ext.and_then(|e| e.get("code")).and_then(Value::as_str) {
112+
let code = code.to_ascii_uppercase();
113+
if code.contains("AUTH") {
114+
return ErrorKind::Auth;
115+
}
116+
if code.contains("RATELIM") {
117+
return ErrorKind::RateLimited;
118+
}
119+
if code.contains("NOT_FOUND") {
120+
return ErrorKind::NotFound;
121+
}
122+
}
123+
124+
// 3. Message text — Linear reports "Entity not found" with statusCode 400.
125+
let msg = error
126+
.get("message")
127+
.and_then(Value::as_str)
128+
.or_else(|| {
129+
ext.and_then(|e| e.get("userPresentableMessage"))
130+
.and_then(Value::as_str)
131+
})
132+
.unwrap_or("");
133+
let lower = msg.to_ascii_lowercase();
134+
if lower.contains("not found") || lower.contains("could not find") {
135+
return ErrorKind::NotFound;
136+
}
137+
138+
ErrorKind::General
139+
}
75140
}
76141

77142
impl fmt::Display for CliError {
@@ -276,4 +341,72 @@ mod tests {
276341
3
277342
);
278343
}
344+
345+
#[test]
346+
fn graphql_entity_not_found_maps_to_not_found() {
347+
// Linear returns "Entity not found" as HTTP 200 + errors array with
348+
// statusCode 400 (NOT 404), so classification must fall through to the
349+
// message-text check. This is the common `i get <bad-id>` path.
350+
let errors = json!([{
351+
"message": "Entity not found: Issue",
352+
"extensions": {
353+
"code": "INPUT_ERROR",
354+
"statusCode": 400,
355+
"type": "invalid input",
356+
"userError": true,
357+
"userPresentableMessage": "Could not find referenced Issue."
358+
}
359+
}]);
360+
let err = CliError::from_graphql_errors(&errors);
361+
assert_eq!(err.kind, ErrorKind::NotFound);
362+
assert_eq!(err.code(), 2);
363+
assert_eq!(err.to_string(), "GraphQL error: Entity not found: Issue");
364+
}
365+
366+
#[test]
367+
fn graphql_auth_error_maps_to_auth() {
368+
let errors = json!([{
369+
"message": "Authentication required",
370+
"extensions": { "code": "AUTHENTICATION_ERROR" }
371+
}]);
372+
let err = CliError::from_graphql_errors(&errors);
373+
assert_eq!(err.kind, ErrorKind::Auth);
374+
assert_eq!(err.code(), 3);
375+
}
376+
377+
#[test]
378+
fn graphql_rate_limit_maps_to_rate_limited_with_retry_after() {
379+
let errors = json!([{
380+
"message": "Too many requests",
381+
"extensions": { "code": "RATELIMITED", "retryAfter": 30 }
382+
}]);
383+
let err = CliError::from_graphql_errors(&errors);
384+
assert_eq!(err.kind, ErrorKind::RateLimited);
385+
assert_eq!(err.code(), 4);
386+
assert_eq!(err.retry_after, Some(30));
387+
}
388+
389+
#[test]
390+
fn graphql_status_code_takes_precedence() {
391+
// A 403 in extensions classifies as Auth even without a code string.
392+
let errors = json!([{
393+
"message": "denied",
394+
"extensions": { "statusCode": 403 }
395+
}]);
396+
assert_eq!(CliError::from_graphql_errors(&errors).code(), 3);
397+
}
398+
399+
#[test]
400+
fn graphql_generic_error_stays_general() {
401+
let errors = json!([{ "message": "Field 'foo' is invalid" }]);
402+
let err = CliError::from_graphql_errors(&errors);
403+
assert_eq!(err.kind, ErrorKind::General);
404+
assert_eq!(err.code(), 1);
405+
}
406+
407+
#[test]
408+
fn graphql_empty_errors_stays_general() {
409+
let errors = json!([]);
410+
assert_eq!(CliError::from_graphql_errors(&errors).kind, ErrorKind::General);
411+
}
279412
}

0 commit comments

Comments
 (0)