Skip to main content

gigastt_core/
error.rs

1//! Error types for the gigastt public API.
2//!
3//! [`GigasttError`] is the primary error type returned by [`Engine`](crate::inference::Engine)
4//! methods. It provides structured error variants so consumers can match on specific
5//! failure modes without downcasting.
6
7use thiserror::Error;
8
9/// A validated model path string.
10///
11/// Invariant: non-empty, valid UTF-8.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ModelPath(String);
14
15impl ModelPath {
16    /// { !s.is_empty() }
17    /// fn new(s: &str) -> Result<ModelPath, GigasttError>
18    /// { ret.as_ref().map(|p| !p.as_str().is_empty()).unwrap_or(true) }
19    pub fn new(s: &str) -> Result<Self, GigasttError> {
20        if s.is_empty() {
21            return Err(GigasttError::InvalidAudio {
22                reason: "empty model path".into(),
23            });
24        }
25        Ok(ModelPath(s.to_string()))
26    }
27
28    /// { true }
29    /// fn as_str(&self) -> &str
30    /// { !ret.is_empty() }
31    /// { true }
32    /// fn as_str(&self) -> &str
33    /// { !ret.is_empty() }
34    pub fn as_str(&self) -> &str {
35        &self.0
36    }
37}
38
39/// A human-readable error reason string.
40///
41/// Invariant: non-empty.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Reason(String);
44
45impl Reason {
46    /// { !s.is_empty() }
47    /// fn new(s: &str) -> Result<Reason, GigasttError>
48    /// { ret.as_ref().map(|r| !r.as_str().is_empty()).unwrap_or(true) }
49    pub fn new(s: &str) -> Result<Self, GigasttError> {
50        if s.is_empty() {
51            return Err(GigasttError::InvalidAudio {
52                reason: "empty error reason".into(),
53            });
54        }
55        Ok(Reason(s.to_string()))
56    }
57
58    /// { true }
59    /// fn as_str(&self) -> &str
60    /// { !ret.is_empty() }
61    pub fn as_str(&self) -> &str {
62        &self.0
63    }
64}
65
66/// Errors returned by gigastt public API methods.
67///
68/// This enum covers the main failure categories:
69/// - Model loading failures ([`ModelLoad`](GigasttError::ModelLoad))
70/// - Runtime inference errors ([`Inference`](GigasttError::Inference))
71/// - Invalid audio input ([`InvalidAudio`](GigasttError::InvalidAudio))
72/// - Filesystem / I/O errors ([`Io`](GigasttError::Io))
73///
74/// # Matching on errors
75///
76/// ```ignore
77/// use gigastt::error::GigasttError;
78///
79/// match err {
80///     GigasttError::ModelLoad { path, .. } => eprintln!("Model problem at {path}"),
81///     GigasttError::Inference { .. } => eprintln!("Inference failed"),
82///     GigasttError::InvalidAudio { reason } => eprintln!("Bad audio: {reason}"),
83///     GigasttError::Io(e) => eprintln!("I/O error: {e}"),
84///     _ => eprintln!("Other error"),
85/// }
86/// ```
87#[derive(Debug, Error)]
88#[non_exhaustive]
89pub enum GigasttError {
90    /// Model files not found or failed to load ONNX sessions.
91    #[error("model load error at {path}")]
92    ModelLoad {
93        /// Path to the model file or directory that failed.
94        path: String,
95        /// Underlying error, if any.
96        #[source]
97        source: Option<Box<dyn std::error::Error + Send + Sync>>,
98    },
99    /// ONNX inference failed during encode, decode, or join.
100    #[error("inference failed")]
101    Inference {
102        /// Underlying error.
103        #[source]
104        source: Box<dyn std::error::Error + Send + Sync>,
105    },
106    /// Invalid audio input (unsupported format, excessive duration, corrupt data).
107    #[error("invalid audio: {reason}")]
108    InvalidAudio {
109        /// Human-readable description of why the audio was rejected.
110        reason: String,
111    },
112    /// Filesystem or I/O error.
113    #[error(transparent)]
114    Io(#[from] std::io::Error),
115    /// Invalid user-supplied parameter or option (not audio-specific).
116    #[error("invalid input: {message}")]
117    InvalidInput { message: String },
118    /// The run was cancelled cooperatively before it finished (client
119    /// disconnect, `DELETE /v1/jobs/{id}`, a fired shutdown signal, or the
120    /// no-progress inference watchdog). The decode loop observes the abort
121    /// signal at a window boundary and returns this so the pooled session is
122    /// released promptly instead of running to completion. Additive: the enum
123    /// is `#[non_exhaustive]`.
124    #[error("cancelled")]
125    Cancelled,
126    /// The audio exceeded a duration limit and was rejected before it could
127    /// exhaust memory. `observed_secs` is how long the decoded input turned out
128    /// to be; `limit_secs` is the ceiling that fired. Two sources trip this: the
129    /// opt-in `--max-audio-secs` (default `0` = unlimited), and the fixed safety
130    /// ceiling that the whole-buffer paths (diarization, `channels=split` —
131    /// including its per-channel Opus decode — and the G.722 / raw telephony
132    /// codecs) keep because they must materialize the entire decoded buffer in
133    /// RAM. The default streaming file path, the VAD file path, and streamed
134    /// OGG/Opus are O(one window) and have no length limit. Additive: the enum
135    /// is `#[non_exhaustive]`.
136    #[error("audio too long: {observed_secs:.0}s exceeds the maximum of {limit_secs:.0}s")]
137    AudioTooLong {
138        /// Observed decoded audio length, in seconds.
139        observed_secs: f64,
140        /// The limit that fired, in seconds.
141        limit_secs: f64,
142    },
143}
144
145impl GigasttError {
146    /// Stable, machine-readable error code for wire payloads (WebSocket /
147    /// SSE `error` events). Lets both streaming surfaces emit the same code
148    /// for the same failure instead of collapsing everything to one generic
149    /// string.
150    pub fn code(&self) -> &'static str {
151        match self {
152            GigasttError::ModelLoad { .. } => "model_load_error",
153            GigasttError::Inference { .. } => "inference_error",
154            GigasttError::InvalidAudio { .. } => "invalid_audio",
155            GigasttError::Io(_) => "io_error",
156            GigasttError::InvalidInput { .. } => "invalid_input",
157            GigasttError::Cancelled => "cancelled",
158            GigasttError::AudioTooLong { .. } => "audio_too_long",
159        }
160    }
161}
162
163impl From<crate::runtime::RuntimeError> for GigasttError {
164    fn from(err: crate::runtime::RuntimeError) -> Self {
165        match err {
166            crate::runtime::RuntimeError::LoadFailed { path, message } => GigasttError::ModelLoad {
167                path: path.to_string_lossy().into_owned(),
168                source: Some(Box::new(std::io::Error::other(message))),
169            },
170            other => GigasttError::Inference {
171                source: Box::new(other),
172            },
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_error_code_maps_variants() {
183        assert_eq!(
184            GigasttError::Inference {
185                source: "boom".into()
186            }
187            .code(),
188            "inference_error"
189        );
190        assert_eq!(
191            GigasttError::InvalidAudio {
192                reason: "bad".into()
193            }
194            .code(),
195            "invalid_audio"
196        );
197        assert_eq!(
198            GigasttError::ModelLoad {
199                path: "x".into(),
200                source: None
201            }
202            .code(),
203            "model_load_error"
204        );
205        assert_eq!(
206            GigasttError::Io(std::io::Error::other("x")).code(),
207            "io_error"
208        );
209        assert_eq!(
210            GigasttError::InvalidInput {
211                message: "bad format".into()
212            }
213            .code(),
214            "invalid_input"
215        );
216        assert_eq!(GigasttError::Cancelled.code(), "cancelled");
217        assert_eq!(
218            GigasttError::AudioTooLong {
219                observed_secs: 4000.0,
220                limit_secs: 1800.0,
221            }
222            .code(),
223            "audio_too_long"
224        );
225    }
226
227    #[test]
228    fn test_cancelled_display() {
229        assert_eq!(GigasttError::Cancelled.to_string(), "cancelled");
230    }
231
232    #[test]
233    fn test_audio_too_long_display_rounds_seconds() {
234        let e = GigasttError::AudioTooLong {
235            observed_secs: 3661.4,
236            limit_secs: 1800.0,
237        };
238        assert_eq!(
239            e.to_string(),
240            "audio too long: 3661s exceeds the maximum of 1800s"
241        );
242    }
243
244    #[test]
245    fn test_audio_too_long_survives_anyhow_downcast() {
246        // The decode layer bails through `anyhow`; the engine seam downcasts the
247        // typed variant back out. This guards that round-trip.
248        let err: anyhow::Error = GigasttError::AudioTooLong {
249            observed_secs: 5000.0,
250            limit_secs: 1800.0,
251        }
252        .into();
253        match err.downcast::<GigasttError>() {
254            Ok(GigasttError::AudioTooLong { limit_secs, .. }) => {
255                assert_eq!(limit_secs, 1800.0);
256            }
257            other => panic!("expected AudioTooLong, got {other:?}"),
258        }
259    }
260
261    #[test]
262    fn test_display_invalid_input() {
263        let e = GigasttError::InvalidInput {
264            message: "unsupported format".into(),
265        };
266        assert_eq!(e.to_string(), "invalid input: unsupported format");
267    }
268
269    #[test]
270    fn test_model_path_rejects_empty() {
271        assert!(ModelPath::new("").is_err());
272    }
273
274    #[test]
275    fn test_model_path_accepts_valid() {
276        let p = ModelPath::new("encoder.onnx").unwrap();
277        assert_eq!(p.as_str(), "encoder.onnx");
278    }
279
280    #[test]
281    fn test_reason_rejects_empty() {
282        assert!(Reason::new("").is_err());
283    }
284
285    #[test]
286    fn test_reason_accepts_valid() {
287        let r = Reason::new("too long").unwrap();
288        assert_eq!(r.as_str(), "too long");
289    }
290
291    #[test]
292    fn test_display_model_load() {
293        let e = GigasttError::ModelLoad {
294            path: "encoder.onnx".into(),
295            source: Some(Box::new(std::io::Error::other("missing weights"))),
296        };
297        assert!(e.to_string().contains("encoder.onnx"));
298    }
299
300    #[test]
301    fn test_display_inference() {
302        let e = GigasttError::Inference {
303            source: Box::new(std::io::Error::other("decoder failed")),
304        };
305        assert_eq!(e.to_string(), "inference failed");
306    }
307
308    #[test]
309    fn test_display_invalid_audio() {
310        let e = GigasttError::InvalidAudio {
311            reason: "too long".into(),
312        };
313        assert_eq!(e.to_string(), "invalid audio: too long");
314    }
315
316    #[test]
317    fn test_display_io() {
318        let e = GigasttError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"));
319        assert!(e.to_string().contains("gone"));
320    }
321
322    #[test]
323    fn test_from_io_error() {
324        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
325        let e: GigasttError = io_err.into();
326        assert!(matches!(e, GigasttError::Io(_)));
327    }
328
329    #[test]
330    fn test_error_source_io() {
331        let e = GigasttError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "x"));
332        assert!(std::error::Error::source(&e).is_none());
333    }
334
335    #[test]
336    fn test_into_anyhow() {
337        // Verify GigasttError works with ? in anyhow::Result contexts
338        fn returns_anyhow() -> anyhow::Result<()> {
339            Err(GigasttError::Inference {
340                source: Box::new(std::io::Error::other("test")),
341            })?;
342            Ok(())
343        }
344        assert!(returns_anyhow().is_err());
345    }
346}