Skip to main content

leash_sdk/
errors.rs

1//! Structured error type for the Leash SDK.
2//!
3//! Mirrors `leash-sdk-ts/src/errors.ts`, `leash-sdk-python/leash/errors.py`,
4//! and `leash-sdk-go/errors.go`. The `code` carried inside each variant is the
5//! stable machine-readable identifier consumers should branch on.
6
7/// Convenience: every async call site in the SDK returns this.
8pub type Result<T> = std::result::Result<T, LeashError>;
9
10/// The structured error returned by every Leash SDK call.
11///
12/// Variants are organised so the common branches (plan blocks, connection
13/// required, upgrades) have first-class shape — no string parsing needed.
14/// For everything else, [`LeashError::UpstreamError`] preserves the HTTP
15/// status and message; [`LeashError::MalformedResponse`] covers parse
16/// failures.
17#[derive(Debug, thiserror::Error)]
18pub enum LeashError {
19    /// HTTP 402 — the caller's plan does not include this feature.
20    ///
21    /// The `required_plan` is best-effort: present when the platform reports
22    /// it in the response body, absent otherwise.
23    #[error("plan block: {message}")]
24    PlanBlock {
25        /// Stable code (`UPGRADE_REQUIRED` from the platform).
26        code: String,
27        /// Human-readable message.
28        message: String,
29        /// The plan tier required to unlock this call, if reported.
30        required_plan: Option<String>,
31    },
32
33    /// HTTP 403 — the user hasn't connected this integration yet.
34    #[error("connection required for {provider}: {message}")]
35    ConnectionRequired {
36        /// Provider id (e.g. `gmail`, `linear`).
37        provider: String,
38        /// Human-readable message.
39        message: String,
40        /// URL the caller can use to start the OAuth flow, when supplied.
41        connect_url: Option<String>,
42    },
43
44    /// HTTP 402 surfaced from `env.get` — Growth plan or above is required.
45    #[error("upgrade required: {message}")]
46    UpgradeRequired {
47        /// Human-readable message.
48        message: String,
49    },
50
51    /// `env.get` was called for a key that isn't declared in `.env.example`
52    /// or doesn't exist in any source.
53    ///
54    /// Note: [`crate::Env::get`] returns `Ok(None)` for this case so callers
55    /// can branch with `if value.is_none()`. This variant only fires when
56    /// callers reach for the lower-level surface and want the explicit error.
57    #[error("env key '{key}' is not declared")]
58    KeyNotDeclared {
59        /// The env-var name that wasn't recognised.
60        key: String,
61    },
62
63    /// HTTP 401 — missing or invalid credential.
64    #[error("unauthorized: {message}")]
65    Unauthorized {
66        /// Human-readable message.
67        message: String,
68    },
69
70    /// Failure below the HTTP layer (DNS, refused connection, TLS, etc.).
71    #[error("network error: {0}")]
72    Network(#[from] reqwest::Error),
73
74    /// An upstream HTTP error that doesn't map onto a more specific variant.
75    #[error("upstream error (HTTP {status}): {message}")]
76    UpstreamError {
77        /// HTTP status code from the platform.
78        status: u16,
79        /// Human-readable message (often pulled from the response body).
80        message: String,
81    },
82
83    /// Platform returned a response we couldn't deserialise.
84    #[error("malformed response: {message}")]
85    MalformedResponse {
86        /// Diagnostic message including the field or shape that failed.
87        message: String,
88    },
89}
90
91impl LeashError {
92    /// True for [`LeashError::PlanBlock`] and [`LeashError::UpgradeRequired`].
93    ///
94    /// Use this to render a single "upgrade your plan" UI without caring which
95    /// surface (integration POST vs `env.get`) tripped the block.
96    pub fn is_plan_block(&self) -> bool {
97        matches!(self, Self::PlanBlock { .. } | Self::UpgradeRequired { .. })
98    }
99
100    /// Alias of [`Self::is_plan_block`] matching the TS / Go naming.
101    pub fn is_upgrade_required(&self) -> bool {
102        self.is_plan_block()
103    }
104
105    /// True for [`LeashError::ConnectionRequired`].
106    pub fn is_connection_required(&self) -> bool {
107        matches!(self, Self::ConnectionRequired { .. })
108    }
109
110    /// True for [`LeashError::Unauthorized`].
111    pub fn is_unauthorized(&self) -> bool {
112        matches!(self, Self::Unauthorized { .. })
113    }
114
115    /// True for [`LeashError::KeyNotDeclared`].
116    pub fn is_key_not_declared(&self) -> bool {
117        matches!(self, Self::KeyNotDeclared { .. })
118    }
119
120    /// True for [`LeashError::Network`].
121    pub fn is_network(&self) -> bool {
122        matches!(self, Self::Network(_))
123    }
124
125    /// Returns the originating HTTP status code, when known.
126    pub fn status(&self) -> Option<u16> {
127        match self {
128            Self::PlanBlock { .. } => Some(402),
129            Self::ConnectionRequired { .. } => Some(403),
130            Self::UpgradeRequired { .. } => Some(402),
131            Self::Unauthorized { .. } => Some(401),
132            Self::UpstreamError { status, .. } => Some(*status),
133            Self::Network(e) => e.status().map(|s| s.as_u16()),
134            Self::KeyNotDeclared { .. } | Self::MalformedResponse { .. } => None,
135        }
136    }
137}
138
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn plan_block_predicate() {
146        let err = LeashError::PlanBlock {
147            code: "UPGRADE_REQUIRED".into(),
148            message: "Growth required".into(),
149            required_plan: Some("growth".into()),
150        };
151        assert!(err.is_plan_block());
152        assert!(err.is_upgrade_required());
153        assert!(!err.is_connection_required());
154        assert_eq!(err.status(), Some(402));
155    }
156
157    #[test]
158    fn connection_required_predicate() {
159        let err = LeashError::ConnectionRequired {
160            provider: "gmail".into(),
161            message: "not connected".into(),
162            connect_url: Some("https://leash.build/connect/gmail".into()),
163        };
164        assert!(err.is_connection_required());
165        assert!(!err.is_plan_block());
166        assert_eq!(err.status(), Some(403));
167    }
168
169    #[test]
170    fn unauthorized_predicate() {
171        let err = LeashError::Unauthorized {
172            message: "nope".into(),
173        };
174        assert!(err.is_unauthorized());
175        assert_eq!(err.status(), Some(401));
176    }
177
178    #[test]
179    fn key_not_declared_predicate() {
180        let err = LeashError::KeyNotDeclared {
181            key: "OPENAI_API_KEY".into(),
182        };
183        assert!(err.is_key_not_declared());
184        assert_eq!(err.status(), None);
185    }
186
187    #[test]
188    fn upstream_error_carries_status() {
189        let err = LeashError::UpstreamError {
190            status: 500,
191            message: "boom".into(),
192        };
193        assert_eq!(err.status(), Some(500));
194        assert!(!err.is_plan_block());
195    }
196
197    #[test]
198    fn malformed_response_has_no_status() {
199        let err = LeashError::MalformedResponse {
200            message: "missing field".into(),
201        };
202        assert_eq!(err.status(), None);
203    }
204}