Skip to main content

rover/summarizer/
error.rs

1//! Errors raised by the summarizer subsystem.
2
3use thiserror::Error;
4
5/// Errors a backend can raise from `compact`.
6#[derive(Debug, Error)]
7pub enum BackendError {
8    #[error("backend unavailable: {0}")]
9    Unavailable(String),
10
11    #[error("rate limited")]
12    RateLimited,
13
14    #[error("auth failed: {0}")]
15    AuthFailed(String),
16
17    #[error("model error: {0}")]
18    ModelError(String),
19
20    #[error("model file {file} has been modified (expected {expected}, got {actual})")]
21    ModelIntegrityFailure {
22        file: String,
23        expected: String,
24        actual: String,
25    },
26
27    /// Invalid request or misuse — both startup-time (e.g. missing
28    /// `base_url` in `CloudBackend::new`) and compact-time (e.g. empty
29    /// content). Distinct from network errors so the service doesn't
30    /// retry through extractive.
31    #[error("invalid request: {0}")]
32    Invalid(String),
33}
34
35/// Errors a `SummarizerService` raises. Wraps `BackendError` with the
36/// originating backend's name so MCP responses can identify the failing
37/// backend in `summarizer_fallback.from`.
38#[derive(Debug, Error)]
39pub enum SummarizerError {
40    #[error("no such backend: {name}")]
41    NoSuchBackend { name: String },
42
43    #[error("no extractive backend configured for fallback")]
44    NoExtractiveBackendForFallback,
45
46    #[error("backend {name} unavailable: {reason}")]
47    BackendUnavailable { name: String, reason: String },
48
49    #[error("backend {name} rate limited")]
50    RateLimited { name: String },
51
52    #[error("backend {name} auth failed: {reason}")]
53    AuthFailed { name: String, reason: String },
54
55    #[error("backend {name} model error: {reason}")]
56    ModelError { name: String, reason: String },
57
58    #[error("invalid request to backend {name}: {reason}")]
59    InvalidRequest { name: String, reason: String },
60
61    #[error("local-inference backend requires the `local-inference` cargo feature")]
62    LocalFeatureNotCompiled,
63
64    #[error("storage error: {0}")]
65    Storage(#[from] crate::storage::StorageError),
66
67    #[error("token counting error: {0}")]
68    Tokenizer(#[from] crate::tokenizer::TokenizerError),
69}
70
71impl SummarizerError {
72    /// Convert a `BackendError` into a `SummarizerError` carrying the
73    /// originating backend's name.
74    pub fn from_backend(name: &str, e: BackendError) -> Self {
75        match e {
76            BackendError::Unavailable(r) => SummarizerError::BackendUnavailable {
77                name: name.to_string(),
78                reason: r,
79            },
80            BackendError::Invalid(r) => SummarizerError::InvalidRequest {
81                name: name.to_string(),
82                reason: r,
83            },
84            BackendError::RateLimited => SummarizerError::RateLimited {
85                name: name.to_string(),
86            },
87            BackendError::AuthFailed(r) => SummarizerError::AuthFailed {
88                name: name.to_string(),
89                reason: r,
90            },
91            BackendError::ModelError(r) => SummarizerError::ModelError {
92                name: name.to_string(),
93                reason: r,
94            },
95            BackendError::ModelIntegrityFailure {
96                file,
97                expected,
98                actual,
99            } => SummarizerError::ModelError {
100                name: name.to_string(),
101                reason: format!(
102                    "model file {file} has been modified (expected {expected}, got {actual})"
103                ),
104            },
105        }
106    }
107
108    /// Short, stable reason string for `summarizer_fallback.reason` metadata.
109    ///
110    /// Only meaningful for variants produced by [`Self::from_backend`]
111    /// (i.e. the five mapped from [`BackendError`]). Storage/Tokenizer/
112    /// NoSuchBackend/NoExtractiveBackendForFallback errors don't flow
113    /// through the fallback path and should never reach this method.
114    pub fn fallback_reason(&self) -> &'static str {
115        match self {
116            SummarizerError::BackendUnavailable { .. } => "backend_unavailable",
117            SummarizerError::InvalidRequest { .. } => "invalid_request",
118            SummarizerError::RateLimited { .. } => "rate_limited",
119            SummarizerError::AuthFailed { .. } => "auth_failed",
120            SummarizerError::ModelError { .. } => "model_error",
121            other => {
122                debug_assert!(
123                    false,
124                    "fallback_reason called on non-fallback-path variant: {other}",
125                );
126                "other"
127            }
128        }
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn from_backend_maps_each_variant() {
138        let cases = [
139            (
140                BackendError::Unavailable("net".into()),
141                "backend_unavailable",
142            ),
143            (BackendError::RateLimited, "rate_limited"),
144            (BackendError::AuthFailed("401".into()), "auth_failed"),
145            (BackendError::ModelError("bad".into()), "model_error"),
146            (BackendError::Invalid("empty".into()), "invalid_request"),
147        ];
148        for (be, expected_reason) in cases {
149            let e = SummarizerError::from_backend("fast", be);
150            assert_eq!(e.fallback_reason(), expected_reason, "for {e}");
151        }
152    }
153
154    #[test]
155    fn storage_error_converts_via_from() {
156        // The exact StorageError variant doesn't matter — just that the
157        // From impl is wired up.
158        let storage_err =
159            crate::storage::StorageError::Backend(tokio_rusqlite::Error::ConnectionClosed);
160        let e: SummarizerError = storage_err.into();
161        assert!(matches!(e, SummarizerError::Storage(_)));
162    }
163
164    #[test]
165    fn tokenizer_error_converts_via_from() {
166        let tok_err = crate::tokenizer::TokenizerError::UnknownFamily("test".to_string());
167        let e: SummarizerError = tok_err.into();
168        assert!(matches!(e, SummarizerError::Tokenizer(_)));
169    }
170}