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}