Skip to main content

sie_sdk/
error.rs

1//! The single error type returned by every fallible SDK operation.
2//!
3//! The Python SDK models failures as an exception hierarchy. Rust has no inheritance, so the
4//! hierarchy collapses into one enum and the `isinstance` checks become the predicates on
5//! [`Error`] ([`Error::is_server_error`], [`Error::status`], [`Error::code`], ...).
6
7use std::time::Duration;
8
9use crate::types::RequestMetadata;
10
11/// Result alias used throughout the crate.
12pub type Result<T> = std::result::Result<T, Error>;
13
14/// Server error codes the SDK reacts to. Any other code is carried through as a string.
15#[allow(missing_docs)]
16pub mod codes {
17    pub const LORA_LOADING: &str = "LORA_LOADING";
18    pub const MODEL_LOADING: &str = "MODEL_LOADING";
19    pub const PROVISIONING: &str = "PROVISIONING";
20    pub const MODEL_LOAD_FAILED: &str = "MODEL_LOAD_FAILED";
21    pub const INPUT_TOO_LONG: &str = "INPUT_TOO_LONG";
22    pub const RESOURCE_EXHAUSTED: &str = "RESOURCE_EXHAUSTED";
23    pub const QUEUE_UNAVAILABLE: &str = "QUEUE_UNAVAILABLE";
24    pub const INTERNAL_ERROR: &str = "INTERNAL_ERROR";
25    pub const ENCODE_RESULT_COUNT_MISMATCH: &str = "ENCODE_RESULT_COUNT_MISMATCH";
26}
27
28/// Why a model failed to load, as classified by the server.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ModelLoadErrorClass {
31    /// Repository requires accepting a licence or supplying a token.
32    Gated,
33    /// Out of device memory while loading.
34    Oom,
35    /// A required runtime dependency is missing.
36    Dependency,
37    /// The model id does not exist.
38    NotFound,
39    /// Transient network failure while fetching weights.
40    Network,
41    /// Anything else.
42    Unknown,
43}
44
45impl ModelLoadErrorClass {
46    fn from_wire(value: &str) -> Self {
47        match value {
48            "GATED" => Self::Gated,
49            "OOM" => Self::Oom,
50            "DEPENDENCY" => Self::Dependency,
51            "NOT_FOUND" => Self::NotFound,
52            "NETWORK" => Self::Network,
53            _ => Self::Unknown,
54        }
55    }
56
57    pub(crate) fn parse(value: Option<&str>) -> Self {
58        value.map_or(Self::Unknown, Self::from_wire)
59    }
60}
61
62/// How a transport-level failure occurred, which decides whether it may be retried.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum TransportErrorKind {
65    /// The connection was never established. Safe to retry for every endpoint.
66    Connect,
67    /// The request was in flight when the failure occurred. Only idempotent endpoints retry.
68    MidFlight,
69    /// The per-attempt timeout elapsed.
70    Timeout,
71}
72
73/// Everything that can go wrong talking to a SIE server.
74///
75/// The variant fields are the failure's evidence: `message` is what the server said,
76/// `code` is its error code, `status` the HTTP status, and `request` the metering metadata
77/// that survived. Read them through the accessors when you only need one.
78#[derive(Debug, thiserror::Error)]
79#[non_exhaustive]
80#[allow(missing_docs)]
81pub enum Error {
82    /// The request never produced an HTTP response.
83    #[error("{message}")]
84    Connection {
85        message: String,
86        kind: TransportErrorKind,
87        #[source]
88        source: Option<Box<dyn std::error::Error + Send + Sync>>,
89    },
90
91    /// A 4xx the SDK does not model more specifically.
92    #[error("{message}")]
93    Request {
94        message: String,
95        code: Option<String>,
96        status: u16,
97        request: Option<Box<RequestMetadata>>,
98    },
99
100    /// A 5xx the SDK does not model more specifically.
101    #[error("{message}")]
102    Server {
103        message: String,
104        code: Option<String>,
105        status: u16,
106        request: Option<Box<RequestMetadata>>,
107    },
108
109    /// The input exceeded the model's context window (400 `INPUT_TOO_LONG`).
110    #[error("{message}")]
111    InputTooLong {
112        message: String,
113        model: Option<String>,
114        request: Option<Box<RequestMetadata>>,
115    },
116
117    /// The worker could not load the model (502 `MODEL_LOAD_FAILED`).
118    #[error("{message}")]
119    ModelLoadFailed {
120        message: String,
121        model: Option<String>,
122        error_class: ModelLoadErrorClass,
123        /// `false` only for cooldown-suppressed OOM/network failures, which may succeed later.
124        permanent: bool,
125        attempts: u32,
126        request: Option<Box<RequestMetadata>>,
127    },
128
129    /// The server ran out of device memory and the SDK exhausted its retries.
130    #[error("{message}")]
131    ResourceExhausted {
132        message: String,
133        model: Option<String>,
134        retries: u32,
135        request: Option<Box<RequestMetadata>>,
136    },
137
138    /// `/v1/estimate` could not route the request to a rate identity.
139    #[error("{message}")]
140    EstimateUnroutable {
141        message: String,
142        code: Option<String>,
143        request: Option<Box<RequestMetadata>>,
144    },
145
146    /// No capacity, and either the caller opted out of waiting or the budget ran out.
147    #[error("{message}")]
148    Provisioning {
149        message: String,
150        gpu: Option<String>,
151        retry_after: Option<Duration>,
152    },
153
154    /// The model was still loading when the provision budget expired.
155    #[error("{message}")]
156    ModelLoading {
157        message: String,
158        model: Option<String>,
159    },
160
161    /// A `LoRA` adapter was still loading after the retry cap.
162    #[error("{message}")]
163    LoraLoading {
164        message: String,
165        lora: Option<String>,
166        model: Option<String>,
167    },
168
169    /// A pool operation failed.
170    #[error("{message}")]
171    Pool {
172        message: String,
173        pool_name: Option<String>,
174        state: Option<String>,
175    },
176
177    /// A response body could not be decoded as the documented shape.
178    #[error("{0}")]
179    Decode(String),
180
181    /// The caller supplied arguments the SDK rejects before sending anything.
182    #[error("{0}")]
183    InvalidRequest(String),
184
185    /// Local I/O failed (reading a file to upload, for example).
186    #[error(transparent)]
187    Io(#[from] std::io::Error),
188}
189
190impl Error {
191    pub(crate) fn decode(message: impl Into<String>) -> Self {
192        Self::Decode(message.into())
193    }
194
195    pub(crate) fn invalid(message: impl Into<String>) -> Self {
196        Self::InvalidRequest(message.into())
197    }
198
199    pub(crate) fn connection(
200        kind: TransportErrorKind,
201        message: impl Into<String>,
202        source: impl std::error::Error + Send + Sync + 'static,
203    ) -> Self {
204        Self::Connection {
205            message: message.into(),
206            kind,
207            source: Some(Box::new(source)),
208        }
209    }
210
211    /// HTTP status that produced this error, when there was a response.
212    pub fn status(&self) -> Option<u16> {
213        match self {
214            Self::Request { status, .. } | Self::Server { status, .. } => Some(*status),
215            Self::InputTooLong { .. } => Some(400),
216            Self::ModelLoadFailed { .. } => Some(502),
217            Self::ResourceExhausted { .. }
218            | Self::EstimateUnroutable { .. }
219            | Self::Provisioning { .. }
220            | Self::ModelLoading { .. } => Some(503),
221            _ => None,
222        }
223    }
224
225    /// Server error code (`X-SIE-Error-Code` or the body's `code` field).
226    pub fn code(&self) -> Option<&str> {
227        match self {
228            Self::Request { code, .. }
229            | Self::Server { code, .. }
230            | Self::EstimateUnroutable { code, .. } => code.as_deref(),
231            Self::InputTooLong { .. } => Some(codes::INPUT_TOO_LONG),
232            Self::ModelLoadFailed { .. } => Some(codes::MODEL_LOAD_FAILED),
233            Self::ResourceExhausted { .. } => Some(codes::RESOURCE_EXHAUSTED),
234            Self::Provisioning { .. } => Some(codes::PROVISIONING),
235            Self::ModelLoading { .. } => Some(codes::MODEL_LOADING),
236            Self::LoraLoading { .. } => Some(codes::LORA_LOADING),
237            _ => None,
238        }
239    }
240
241    /// Metadata the server attached to the failing request, when any survived parsing.
242    pub fn request_metadata(&self) -> Option<&RequestMetadata> {
243        #[allow(clippy::borrowed_box)]
244        match self {
245            Self::Request { request, .. }
246            | Self::Server { request, .. }
247            | Self::InputTooLong { request, .. }
248            | Self::ModelLoadFailed { request, .. }
249            | Self::ResourceExhausted { request, .. }
250            | Self::EstimateUnroutable { request, .. } => request.as_deref(),
251            _ => None,
252        }
253    }
254
255    /// The server's `Retry-After` hint, when it sent one and the SDK gave up anyway.
256    pub fn retry_after(&self) -> Option<Duration> {
257        match self {
258            Self::Provisioning { retry_after, .. } => *retry_after,
259            _ => None,
260        }
261    }
262
263    /// True for every failure the server attributed to itself (5xx family).
264    pub fn is_server_error(&self) -> bool {
265        matches!(
266            self,
267            Self::Server { .. }
268                | Self::ModelLoadFailed { .. }
269                | Self::ResourceExhausted { .. }
270                | Self::EstimateUnroutable { .. }
271        )
272    }
273
274    /// True for every failure the server attributed to the request (4xx family).
275    pub fn is_request_error(&self) -> bool {
276        matches!(self, Self::Request { .. } | Self::InputTooLong { .. })
277    }
278
279    /// True when the SDK gave up waiting for capacity rather than hitting a hard failure.
280    pub fn is_capacity_error(&self) -> bool {
281        matches!(
282            self,
283            Self::Provisioning { .. }
284                | Self::ModelLoading { .. }
285                | Self::LoraLoading { .. }
286                | Self::ResourceExhausted { .. }
287        )
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn predicates_follow_the_python_hierarchy() {
297        let exhausted = Error::ResourceExhausted {
298            message: "boom".into(),
299            model: None,
300            retries: 3,
301            request: None,
302        };
303        assert!(exhausted.is_server_error());
304        assert!(exhausted.is_capacity_error());
305        assert_eq!(exhausted.status(), Some(503));
306        assert_eq!(exhausted.code(), Some(codes::RESOURCE_EXHAUSTED));
307
308        let too_long = Error::InputTooLong {
309            message: "too long".into(),
310            model: Some("m".into()),
311            request: None,
312        };
313        assert!(too_long.is_request_error());
314        assert!(!too_long.is_server_error());
315        assert_eq!(too_long.status(), Some(400));
316    }
317
318    #[test]
319    fn model_load_error_class_defaults_to_unknown() {
320        assert_eq!(
321            ModelLoadErrorClass::parse(Some("GATED")),
322            ModelLoadErrorClass::Gated
323        );
324        assert_eq!(
325            ModelLoadErrorClass::parse(Some("nonsense")),
326            ModelLoadErrorClass::Unknown
327        );
328        assert_eq!(
329            ModelLoadErrorClass::parse(None),
330            ModelLoadErrorClass::Unknown
331        );
332    }
333}