Skip to main content

linear_api/
error.rs

1//! Error taxonomy for the crate: transport, HTTP, GraphQL, rate-limit, and
2//! decode failures, plus Linear's typed error extensions.
3
4use crate::client::RateLimitInfo;
5
6/// Crate-wide result alias defaulting the error type to [`Error`].
7pub type Result<T, E = Error> = std::result::Result<T, E>;
8
9/// Everything that can go wrong talking to the Linear API.
10#[derive(Debug, thiserror::Error)]
11#[non_exhaustive]
12pub enum Error {
13    /// Client construction or configuration failure (missing API key,
14    /// invalid endpoint URL, unset environment variable, …).
15    #[error("configuration error: {0}")]
16    Config(String),
17
18    /// Network-level failure (connect, TLS, timeout). Mutations are NOT
19    /// retried on post-send transport errors by default: Linear has no
20    /// idempotency keys, so a timed-out `issueCreate` may have landed.
21    #[error("transport error")]
22    Transport(#[source] reqwest::Error),
23
24    /// Non-2xx HTTP response without a usable GraphQL error body.
25    #[error("HTTP {status}")]
26    Http {
27        /// HTTP status code.
28        status: u16,
29        /// Raw response body (possibly truncated or non-JSON).
30        body: String,
31    },
32
33    /// The server executed the request and returned GraphQL errors.
34    /// Fail-closed: raised even when partial `data` accompanied the errors
35    /// ([`LinearClient::execute_raw`](crate::LinearClient::execute_raw) is
36    /// the lenient escape hatch).
37    #[error("GraphQL error in {operation}: {}", errors.first().map(|e| e.message.as_str()).unwrap_or("unknown"))]
38    Api {
39        /// Operation name of the failing document.
40        operation: &'static str,
41        /// The GraphQL errors as returned on the wire.
42        errors: Vec<GraphQlError>,
43    },
44
45    /// Rate limited and the required wait exceeded
46    /// [`RetryConfig::max_rate_limit_wait`](crate::RetryConfig), or retries
47    /// were exhausted.
48    #[error("rate limited")]
49    RateLimited {
50        /// Server-indicated wait before the budget resets, when known.
51        retry_after: Option<std::time::Duration>,
52        /// Rate-limit budget snapshot from the rejecting response's headers.
53        info: Option<RateLimitInfo>,
54    },
55
56    /// The response `data` did not match this crate's models — the runtime
57    /// schema-drift tripwire.
58    #[error("could not decode {operation} response (schema drift?)")]
59    Decode {
60        /// Operation name of the failing document.
61        operation: &'static str,
62        /// Underlying deserialization error.
63        #[source]
64        source: serde_json::Error,
65    },
66
67    /// The server returned neither `data` nor `errors`.
68    #[error("{operation} returned no data")]
69    MissingData {
70        /// Operation name of the failing document.
71        operation: &'static str,
72    },
73
74    /// A mutation payload reported `success: false` without GraphQL errors.
75    #[error("{operation} reported success=false")]
76    MutationFailed {
77        /// Operation name of the failing document.
78        operation: &'static str,
79    },
80}
81
82impl Error {
83    /// `true` for [`Error::RateLimited`], or an [`Error::Api`] carrying a
84    /// `RATELIMITED`-typed GraphQL error.
85    pub fn is_rate_limited(&self) -> bool {
86        matches!(self, Error::RateLimited { .. })
87            || self.error_types().contains(&LinearErrorType::Ratelimited)
88    }
89
90    /// `true` when any attached GraphQL error is an authentication failure.
91    pub fn is_authentication(&self) -> bool {
92        self.error_types()
93            .contains(&LinearErrorType::AuthenticationError)
94    }
95
96    /// Typed Linear error classifications attached to this error (empty for
97    /// non-[`Error::Api`] variants and for errors without extensions).
98    pub fn error_types(&self) -> Vec<LinearErrorType> {
99        match self {
100            Error::Api { errors, .. } => errors
101                .iter()
102                .filter_map(|e| e.extensions.as_ref())
103                .map(ErrorExtensions::typed)
104                .collect(),
105            _ => Vec::new(),
106        }
107    }
108}
109
110/// One GraphQL error object as returned by Linear.
111#[derive(Debug, Clone, serde::Deserialize)]
112#[non_exhaustive]
113pub struct GraphQlError {
114    /// Human-readable error message.
115    pub message: String,
116    /// Response path the error applies to, when field-level.
117    #[serde(default)]
118    pub path: Option<Vec<serde_json::Value>>,
119    /// Linear-specific error metadata.
120    #[serde(default)]
121    pub extensions: Option<ErrorExtensions>,
122}
123
124/// Linear's `extensions` object on GraphQL errors.
125#[derive(Debug, Clone, serde::Deserialize)]
126#[non_exhaustive]
127pub struct ErrorExtensions {
128    /// Raw wire error type, e.g. `"authentication error"`. Parse with
129    /// [`ErrorExtensions::typed`].
130    #[serde(default, rename = "type")]
131    pub error_type: Option<String>,
132    /// Machine code, e.g. `"RATELIMITED"`.
133    #[serde(default)]
134    pub code: Option<String>,
135    /// Whether the error is safe to show to end users.
136    #[serde(default, rename = "userError")]
137    pub user_error: Option<bool>,
138    /// A message intended for direct display to end users.
139    #[serde(default, rename = "userPresentableMessage")]
140    pub user_presentable_message: Option<String>,
141}
142
143impl ErrorExtensions {
144    /// Parses the wire `type` (falling back to `code`) into a
145    /// [`LinearErrorType`]. Returns [`LinearErrorType::Unknown`] when neither
146    /// is present.
147    pub fn typed(&self) -> LinearErrorType {
148        self.error_type
149            .as_deref()
150            .or(self.code.as_deref())
151            .map(parse_error_type)
152            .unwrap_or(LinearErrorType::Unknown)
153    }
154}
155
156/// Typed classification of Linear API errors, mirroring `@linear/sdk`'s
157/// `LinearErrorType`. Wire strings vary in casing and separators (e.g.
158/// `"authentication error"`), so parsing is case-, space-, underscore-, and
159/// hyphen-insensitive; unmatched values are preserved in
160/// [`LinearErrorType::Unrecognized`].
161#[derive(Debug, Clone, PartialEq, Eq)]
162#[non_exhaustive]
163pub enum LinearErrorType {
164    /// The requested feature is not accessible on this workspace/plan.
165    FeatureNotAccessible,
166    /// Invalid mutation/query input.
167    InvalidInput,
168    /// Request was rejected by the rate limiter.
169    Ratelimited,
170    /// Server-side network failure.
171    NetworkError,
172    /// Missing or invalid credentials.
173    AuthenticationError,
174    /// Authenticated but not permitted.
175    Forbidden,
176    /// Failure while bootstrapping workspace data.
177    BootstrapError,
178    /// Unknown error (Linear's own catch-all).
179    Unknown,
180    /// Internal server error.
181    InternalError,
182    /// Other, uncategorized error.
183    Other,
184    /// A user-facing validation error.
185    UserError,
186    /// GraphQL-level error (malformed document, unknown field, …).
187    GraphqlError,
188    /// A lock could not be acquired in time.
189    LockTimeout,
190    /// A usage limit was exceeded.
191    UsageLimitExceeded,
192    /// A wire value this crate does not recognize (kept verbatim).
193    Unrecognized(String),
194}
195
196fn parse_error_type(raw: &str) -> LinearErrorType {
197    let normalized: String = raw
198        .to_ascii_lowercase()
199        .chars()
200        .filter(|c| !matches!(c, ' ' | '_' | '-'))
201        .collect();
202    match normalized.as_str() {
203        "featurenotaccessible" => LinearErrorType::FeatureNotAccessible,
204        "invalidinput" => LinearErrorType::InvalidInput,
205        "ratelimited" => LinearErrorType::Ratelimited,
206        "networkerror" => LinearErrorType::NetworkError,
207        "authenticationerror" => LinearErrorType::AuthenticationError,
208        "forbidden" => LinearErrorType::Forbidden,
209        "bootstraperror" => LinearErrorType::BootstrapError,
210        "unknown" => LinearErrorType::Unknown,
211        "internalerror" => LinearErrorType::InternalError,
212        "other" => LinearErrorType::Other,
213        "usererror" => LinearErrorType::UserError,
214        "graphqlerror" => LinearErrorType::GraphqlError,
215        "locktimeout" => LinearErrorType::LockTimeout,
216        "usagelimitexceeded" => LinearErrorType::UsageLimitExceeded,
217        _ => LinearErrorType::Unrecognized(raw.to_string()),
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn parses_wire_variants_insensitively() {
227        assert_eq!(
228            parse_error_type("authentication error"),
229            LinearErrorType::AuthenticationError
230        );
231        assert_eq!(
232            parse_error_type("AUTHENTICATION_ERROR"),
233            LinearErrorType::AuthenticationError
234        );
235        assert_eq!(
236            parse_error_type("Ratelimited"),
237            LinearErrorType::Ratelimited
238        );
239        assert_eq!(
240            parse_error_type("RATELIMITED"),
241            LinearErrorType::Ratelimited
242        );
243        assert_eq!(
244            parse_error_type("usage-limit-exceeded"),
245            LinearErrorType::UsageLimitExceeded
246        );
247        assert_eq!(
248            parse_error_type("brand new thing"),
249            LinearErrorType::Unrecognized("brand new thing".to_string())
250        );
251    }
252
253    #[test]
254    fn typed_falls_back_to_code_then_unknown() {
255        let ext: ErrorExtensions =
256            serde_json::from_value(serde_json::json!({ "code": "RATELIMITED" })).unwrap();
257        assert_eq!(ext.typed(), LinearErrorType::Ratelimited);
258
259        let ext: ErrorExtensions = serde_json::from_value(serde_json::json!({})).unwrap();
260        assert_eq!(ext.typed(), LinearErrorType::Unknown);
261    }
262}