Skip to main content

tessera/
error.rs

1//! Error taxonomy mirroring the Python SDK's exception hierarchy.
2//!
3//! Python's distinct exception classes map onto a single `#[non_exhaustive]`
4//! enum; users `match` on [`TesseraError::NotFound`] instead of `except`.
5//! Python's `MissingDependencyError` is deliberately omitted: Rust encodes
6//! "optional dependency" as cargo features, so a disabled feature means the
7//! method is absent at compile time rather than a runtime error.
8
9/// Every error raised by this crate.
10#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
11#[non_exhaustive]
12pub enum TesseraError {
13    /// The client was misconfigured — e.g. no API key could be resolved.
14    #[error("{0}")]
15    Configuration(String),
16    /// A request argument was invalid (month format, empty coin/month, reversed span).
17    #[error("{0}")]
18    InvalidArgument(String),
19    /// A network-level failure (connection/timeout) after exhausting retries.
20    #[error("network error contacting Tessera: {0}")]
21    Network(String),
22    /// A presigned download URL expired before the read completed.
23    #[error(
24        "A presigned download URL was rejected (likely expired). Presigned URLs are short-lived — call read()/scan() again to mint fresh ones."
25    )]
26    PresignExpired,
27    /// 400 — the request was malformed (bad coin, month format, etc.).
28    #[error("{message}")]
29    BadRequest {
30        /// Machine-readable error code from the `{"error": ...}` body, if any.
31        code: Option<String>,
32        /// Human-readable message.
33        message: String,
34    },
35    /// 401 — the API key is missing, invalid, or revoked.
36    #[error("{message}")]
37    Authentication {
38        /// Machine-readable error code from the `{"error": ...}` body, if any.
39        code: Option<String>,
40        /// Human-readable message.
41        message: String,
42    },
43    /// 403 — your plan does not grant access to this dataset or coin.
44    #[error("{message}")]
45    Forbidden {
46        /// Machine-readable error code from the `{"error": ...}` body, if any.
47        code: Option<String>,
48        /// Human-readable message.
49        message: String,
50    },
51    /// 404 — the dataset, coin, or partition does not exist.
52    #[error("{message}")]
53    NotFound {
54        /// Machine-readable error code from the `{"error": ...}` body, if any.
55        code: Option<String>,
56        /// Human-readable message.
57        message: String,
58    },
59    /// 503 — the catalog is temporarily unavailable. Safe to retry.
60    #[error("{message}")]
61    ServiceUnavailable {
62        /// Machine-readable error code from the `{"error": ...}` body, if any.
63        code: Option<String>,
64        /// Human-readable message.
65        message: String,
66    },
67    /// 500 — an unexpected server error.
68    #[error("{message}")]
69    InternalServer {
70        /// Machine-readable error code from the `{"error": ...}` body, if any.
71        code: Option<String>,
72        /// Human-readable message.
73        message: String,
74    },
75    /// Any other error response, carrying its raw status code.
76    #[error("{message}")]
77    Api {
78        /// HTTP status code of the response.
79        status_code: u16,
80        /// Machine-readable error code from the `{"error": ...}` body, if any.
81        code: Option<String>,
82        /// Human-readable message.
83        message: String,
84    },
85}
86
87impl TesseraError {
88    /// The HTTP status code for API-derived errors; `None` for client-side errors.
89    #[must_use]
90    pub fn status_code(&self) -> Option<u16> {
91        match self {
92            Self::BadRequest { .. } => Some(400),
93            Self::Authentication { .. } => Some(401),
94            Self::Forbidden { .. } => Some(403),
95            Self::NotFound { .. } => Some(404),
96            Self::ServiceUnavailable { .. } => Some(503),
97            Self::InternalServer { .. } => Some(500),
98            Self::Api { status_code, .. } => Some(*status_code),
99            Self::Configuration(_)
100            | Self::InvalidArgument(_)
101            | Self::Network(_)
102            | Self::PresignExpired => None,
103        }
104    }
105
106    /// The machine-readable error code from the response body, if any.
107    #[must_use]
108    pub fn code(&self) -> Option<&str> {
109        match self {
110            Self::BadRequest { code, .. }
111            | Self::Authentication { code, .. }
112            | Self::Forbidden { code, .. }
113            | Self::NotFound { code, .. }
114            | Self::ServiceUnavailable { code, .. }
115            | Self::InternalServer { code, .. }
116            | Self::Api { code, .. } => code.as_deref(),
117            Self::Configuration(_)
118            | Self::InvalidArgument(_)
119            | Self::Network(_)
120            | Self::PresignExpired => None,
121        }
122    }
123}
124
125/// Build the appropriate [`TesseraError`] from an error response.
126///
127/// Prefers the `{"error": "<code>"}` body; falls back to the HTTP status.
128pub fn error_from_response(status_code: u16, body: &[u8]) -> TesseraError {
129    let code: Option<String> = serde_json::from_slice::<serde_json::Value>(body)
130        .ok()
131        .and_then(|parsed| {
132            parsed
133                .get("error")
134                .and_then(serde_json::Value::as_str)
135                .map(ToOwned::to_owned)
136        });
137
138    let detail = code
139        .as_ref()
140        .map_or_else(String::new, |c| format!(" ({c})"));
141    let message = format!("Tessera API request failed with HTTP {status_code}{detail}");
142
143    if let Some(c) = &code {
144        return match c.as_str() {
145            "bad_request" => TesseraError::BadRequest {
146                code: Some(c.clone()),
147                message,
148            },
149            "unauthorized" => TesseraError::Authentication {
150                code: Some(c.clone()),
151                message,
152            },
153            "forbidden" => TesseraError::Forbidden {
154                code: Some(c.clone()),
155                message,
156            },
157            "not_found" => TesseraError::NotFound {
158                code: Some(c.clone()),
159                message,
160            },
161            "unavailable" => TesseraError::ServiceUnavailable {
162                code: Some(c.clone()),
163                message,
164            },
165            "internal" => TesseraError::InternalServer {
166                code: Some(c.clone()),
167                message,
168            },
169            _ => TesseraError::Api {
170                status_code,
171                code: Some(c.clone()),
172                message,
173            },
174        };
175    }
176    match status_code {
177        400 => TesseraError::BadRequest {
178            code: None,
179            message,
180        },
181        401 => TesseraError::Authentication {
182            code: None,
183            message,
184        },
185        403 => TesseraError::Forbidden {
186            code: None,
187            message,
188        },
189        404 => TesseraError::NotFound {
190            code: None,
191            message,
192        },
193        500 => TesseraError::InternalServer {
194            code: None,
195            message,
196        },
197        502..=504 => TesseraError::ServiceUnavailable {
198            code: None,
199            message,
200        },
201        _ => TesseraError::Api {
202            status_code,
203            code: None,
204            message,
205        },
206    }
207}