linear-api 0.1.0

Unofficial async Rust client for the Linear GraphQL API (API-key auth)
Documentation
//! Error taxonomy for the crate: transport, HTTP, GraphQL, rate-limit, and
//! decode failures, plus Linear's typed error extensions.

use crate::client::RateLimitInfo;

/// Crate-wide result alias defaulting the error type to [`Error`].
pub type Result<T, E = Error> = std::result::Result<T, E>;

/// Everything that can go wrong talking to the Linear API.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// Client construction or configuration failure (missing API key,
    /// invalid endpoint URL, unset environment variable, …).
    #[error("configuration error: {0}")]
    Config(String),

    /// Network-level failure (connect, TLS, timeout). Mutations are NOT
    /// retried on post-send transport errors by default: Linear has no
    /// idempotency keys, so a timed-out `issueCreate` may have landed.
    #[error("transport error")]
    Transport(#[source] reqwest::Error),

    /// Non-2xx HTTP response without a usable GraphQL error body.
    #[error("HTTP {status}")]
    Http {
        /// HTTP status code.
        status: u16,
        /// Raw response body (possibly truncated or non-JSON).
        body: String,
    },

    /// The server executed the request and returned GraphQL errors.
    /// Fail-closed: raised even when partial `data` accompanied the errors
    /// ([`LinearClient::execute_raw`](crate::LinearClient::execute_raw) is
    /// the lenient escape hatch).
    #[error("GraphQL error in {operation}: {}", errors.first().map(|e| e.message.as_str()).unwrap_or("unknown"))]
    Api {
        /// Operation name of the failing document.
        operation: &'static str,
        /// The GraphQL errors as returned on the wire.
        errors: Vec<GraphQlError>,
    },

    /// Rate limited and the required wait exceeded
    /// [`RetryConfig::max_rate_limit_wait`](crate::RetryConfig), or retries
    /// were exhausted.
    #[error("rate limited")]
    RateLimited {
        /// Server-indicated wait before the budget resets, when known.
        retry_after: Option<std::time::Duration>,
        /// Rate-limit budget snapshot from the rejecting response's headers.
        info: Option<RateLimitInfo>,
    },

    /// The response `data` did not match this crate's models — the runtime
    /// schema-drift tripwire.
    #[error("could not decode {operation} response (schema drift?)")]
    Decode {
        /// Operation name of the failing document.
        operation: &'static str,
        /// Underlying deserialization error.
        #[source]
        source: serde_json::Error,
    },

    /// The server returned neither `data` nor `errors`.
    #[error("{operation} returned no data")]
    MissingData {
        /// Operation name of the failing document.
        operation: &'static str,
    },

    /// A mutation payload reported `success: false` without GraphQL errors.
    #[error("{operation} reported success=false")]
    MutationFailed {
        /// Operation name of the failing document.
        operation: &'static str,
    },
}

impl Error {
    /// `true` for [`Error::RateLimited`], or an [`Error::Api`] carrying a
    /// `RATELIMITED`-typed GraphQL error.
    pub fn is_rate_limited(&self) -> bool {
        matches!(self, Error::RateLimited { .. })
            || self.error_types().contains(&LinearErrorType::Ratelimited)
    }

    /// `true` when any attached GraphQL error is an authentication failure.
    pub fn is_authentication(&self) -> bool {
        self.error_types()
            .contains(&LinearErrorType::AuthenticationError)
    }

    /// Typed Linear error classifications attached to this error (empty for
    /// non-[`Error::Api`] variants and for errors without extensions).
    pub fn error_types(&self) -> Vec<LinearErrorType> {
        match self {
            Error::Api { errors, .. } => errors
                .iter()
                .filter_map(|e| e.extensions.as_ref())
                .map(ErrorExtensions::typed)
                .collect(),
            _ => Vec::new(),
        }
    }
}

/// One GraphQL error object as returned by Linear.
#[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct GraphQlError {
    /// Human-readable error message.
    pub message: String,
    /// Response path the error applies to, when field-level.
    #[serde(default)]
    pub path: Option<Vec<serde_json::Value>>,
    /// Linear-specific error metadata.
    #[serde(default)]
    pub extensions: Option<ErrorExtensions>,
}

/// Linear's `extensions` object on GraphQL errors.
#[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct ErrorExtensions {
    /// Raw wire error type, e.g. `"authentication error"`. Parse with
    /// [`ErrorExtensions::typed`].
    #[serde(default, rename = "type")]
    pub error_type: Option<String>,
    /// Machine code, e.g. `"RATELIMITED"`.
    #[serde(default)]
    pub code: Option<String>,
    /// Whether the error is safe to show to end users.
    #[serde(default, rename = "userError")]
    pub user_error: Option<bool>,
    /// A message intended for direct display to end users.
    #[serde(default, rename = "userPresentableMessage")]
    pub user_presentable_message: Option<String>,
}

impl ErrorExtensions {
    /// Parses the wire `type` (falling back to `code`) into a
    /// [`LinearErrorType`]. Returns [`LinearErrorType::Unknown`] when neither
    /// is present.
    pub fn typed(&self) -> LinearErrorType {
        self.error_type
            .as_deref()
            .or(self.code.as_deref())
            .map(parse_error_type)
            .unwrap_or(LinearErrorType::Unknown)
    }
}

/// Typed classification of Linear API errors, mirroring `@linear/sdk`'s
/// `LinearErrorType`. Wire strings vary in casing and separators (e.g.
/// `"authentication error"`), so parsing is case-, space-, underscore-, and
/// hyphen-insensitive; unmatched values are preserved in
/// [`LinearErrorType::Unrecognized`].
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum LinearErrorType {
    /// The requested feature is not accessible on this workspace/plan.
    FeatureNotAccessible,
    /// Invalid mutation/query input.
    InvalidInput,
    /// Request was rejected by the rate limiter.
    Ratelimited,
    /// Server-side network failure.
    NetworkError,
    /// Missing or invalid credentials.
    AuthenticationError,
    /// Authenticated but not permitted.
    Forbidden,
    /// Failure while bootstrapping workspace data.
    BootstrapError,
    /// Unknown error (Linear's own catch-all).
    Unknown,
    /// Internal server error.
    InternalError,
    /// Other, uncategorized error.
    Other,
    /// A user-facing validation error.
    UserError,
    /// GraphQL-level error (malformed document, unknown field, …).
    GraphqlError,
    /// A lock could not be acquired in time.
    LockTimeout,
    /// A usage limit was exceeded.
    UsageLimitExceeded,
    /// A wire value this crate does not recognize (kept verbatim).
    Unrecognized(String),
}

fn parse_error_type(raw: &str) -> LinearErrorType {
    let normalized: String = raw
        .to_ascii_lowercase()
        .chars()
        .filter(|c| !matches!(c, ' ' | '_' | '-'))
        .collect();
    match normalized.as_str() {
        "featurenotaccessible" => LinearErrorType::FeatureNotAccessible,
        "invalidinput" => LinearErrorType::InvalidInput,
        "ratelimited" => LinearErrorType::Ratelimited,
        "networkerror" => LinearErrorType::NetworkError,
        "authenticationerror" => LinearErrorType::AuthenticationError,
        "forbidden" => LinearErrorType::Forbidden,
        "bootstraperror" => LinearErrorType::BootstrapError,
        "unknown" => LinearErrorType::Unknown,
        "internalerror" => LinearErrorType::InternalError,
        "other" => LinearErrorType::Other,
        "usererror" => LinearErrorType::UserError,
        "graphqlerror" => LinearErrorType::GraphqlError,
        "locktimeout" => LinearErrorType::LockTimeout,
        "usagelimitexceeded" => LinearErrorType::UsageLimitExceeded,
        _ => LinearErrorType::Unrecognized(raw.to_string()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_wire_variants_insensitively() {
        assert_eq!(
            parse_error_type("authentication error"),
            LinearErrorType::AuthenticationError
        );
        assert_eq!(
            parse_error_type("AUTHENTICATION_ERROR"),
            LinearErrorType::AuthenticationError
        );
        assert_eq!(
            parse_error_type("Ratelimited"),
            LinearErrorType::Ratelimited
        );
        assert_eq!(
            parse_error_type("RATELIMITED"),
            LinearErrorType::Ratelimited
        );
        assert_eq!(
            parse_error_type("usage-limit-exceeded"),
            LinearErrorType::UsageLimitExceeded
        );
        assert_eq!(
            parse_error_type("brand new thing"),
            LinearErrorType::Unrecognized("brand new thing".to_string())
        );
    }

    #[test]
    fn typed_falls_back_to_code_then_unknown() {
        let ext: ErrorExtensions =
            serde_json::from_value(serde_json::json!({ "code": "RATELIMITED" })).unwrap();
        assert_eq!(ext.typed(), LinearErrorType::Ratelimited);

        let ext: ErrorExtensions = serde_json::from_value(serde_json::json!({})).unwrap();
        assert_eq!(ext.typed(), LinearErrorType::Unknown);
    }
}