Skip to main content

mermaid_cli/models/
error.rs

1//! Comprehensive error types for the model system
2//!
3//! Replaces scattered anyhow::Error usage with structured, actionable errors
4//! that enable proper recovery, retry logic, and user-friendly messages.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9/// User-facing error information with actionable suggestions
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct UserFacingError {
12    /// Short summary for status bar (e.g., "Connection failed")
13    pub summary: String,
14    /// Detailed message for chat display
15    pub message: String,
16    /// Actionable suggestion for the user
17    pub suggestion: String,
18    /// Error category for styling/icons
19    pub category: ErrorCategory,
20    /// Whether this error is recoverable (user can retry)
21    pub recoverable: bool,
22}
23
24/// Error categories for visual differentiation
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26pub enum ErrorCategory {
27    /// Connection/network issues
28    Connection,
29    /// Authentication/authorization issues
30    Auth,
31    /// Configuration issues
32    Config,
33    /// Resource not found
34    NotFound,
35    /// Temporary issue (rate limit, timeout)
36    Temporary,
37    /// Internal/unexpected error
38    Internal,
39}
40
41/// Top-level error type for all model operations
42#[derive(Debug)]
43pub enum ModelError {
44    /// Backend-specific error (connection, API, etc)
45    Backend(BackendError),
46
47    /// Configuration error (invalid settings, missing keys, etc)
48    Config(ConfigError),
49
50    /// Model not found or unavailable
51    ModelNotFound {
52        model: String,
53        searched: Vec<String>,
54    },
55
56    /// Request timeout
57    Timeout {
58        operation: String,
59        duration_secs: u64,
60    },
61
62    /// Rate limit exceeded
63    RateLimit { retry_after: Option<u64> },
64
65    /// Invalid request (malformed input, bad parameters)
66    InvalidRequest(String),
67
68    /// Response parsing error
69    ParseError {
70        message: String,
71        raw: Option<String>,
72    },
73
74    /// Stream error (connection dropped, incomplete response)
75    StreamError(String),
76
77    /// Authentication error
78    Authentication(String),
79
80    /// The adapter does not implement the requested feature (e.g. an
81    /// Anthropic adapter has no `list_models` endpoint, so the trait's
82    /// default impl returns this).
83    Unsupported { feature: String },
84
85    /// The provider call was aborted by the turn's cancellation
86    /// token. The effect runner swallows this silently — the
87    /// terminal `Msg::TurnCancelled` is emitted from `drop_scope`
88    /// after the scope's `JoinSet` drains, so no `UpstreamError`
89    /// should reach the reducer for cancelled turns.
90    Cancelled,
91}
92
93impl fmt::Display for ModelError {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        match self {
96            ModelError::Backend(e) => write!(f, "Backend error: {}", e),
97            ModelError::Config(e) => write!(f, "Configuration error: {}", e),
98            ModelError::ModelNotFound { model, searched } => {
99                write!(
100                    f,
101                    "Model '{}' not found. Searched: {}",
102                    model,
103                    searched.join(", ")
104                )
105            },
106            ModelError::Timeout {
107                operation,
108                duration_secs,
109            } => {
110                if *duration_secs == 0 {
111                    write!(f, "Operation '{}' timed out", operation)
112                } else {
113                    write!(
114                        f,
115                        "Operation '{}' timed out after {} seconds",
116                        operation, duration_secs
117                    )
118                }
119            },
120            ModelError::RateLimit { retry_after } => {
121                if let Some(secs) = retry_after {
122                    write!(f, "Rate limit exceeded. Retry after {} seconds", secs)
123                } else {
124                    write!(f, "Rate limit exceeded")
125                }
126            },
127            ModelError::InvalidRequest(msg) => write!(f, "Invalid request: {}", msg),
128            ModelError::ParseError { message, raw } => {
129                if let Some(r) = raw {
130                    write!(f, "Parse error: {} (raw: {})", message, r)
131                } else {
132                    write!(f, "Parse error: {}", message)
133                }
134            },
135            ModelError::StreamError(msg) => write!(f, "Stream error: {}", msg),
136            ModelError::Authentication(msg) => write!(f, "Authentication error: {}", msg),
137            ModelError::Unsupported { feature } => {
138                write!(f, "Feature not supported by this adapter: {}", feature)
139            },
140            ModelError::Cancelled => write!(f, "Cancelled by user"),
141        }
142    }
143}
144
145impl std::error::Error for ModelError {}
146
147impl ModelError {
148    /// Convert to user-facing error with actionable suggestions
149    pub fn to_user_facing(&self) -> UserFacingError {
150        match self {
151            ModelError::Backend(BackendError::ConnectionFailed { backend, url, .. }) => {
152                UserFacingError {
153                    summary: format!("{} connection failed", backend),
154                    message: format!("Could not connect to {} at {}", backend, url),
155                    suggestion: if backend == "ollama" {
156                        "Run 'ollama serve' to start Ollama, or check if it's running on the correct port".to_string()
157                    } else {
158                        format!("Check if {} is running and accessible", backend)
159                    },
160                    category: ErrorCategory::Connection,
161                    recoverable: true,
162                }
163            },
164            ModelError::Backend(BackendError::NotAvailable { backend, reason }) => {
165                UserFacingError {
166                    summary: format!("{} unavailable", backend),
167                    message: format!("{} is not available: {}", backend, reason),
168                    suggestion: if backend == "ollama" {
169                        "Start Ollama with 'ollama serve' or pull the model with 'ollama pull <model>'".to_string()
170                    } else {
171                        format!("Ensure {} service is running and healthy", backend)
172                    },
173                    category: ErrorCategory::Connection,
174                    recoverable: true,
175                }
176            },
177            ModelError::Backend(BackendError::HttpError { status, message }) => {
178                let (summary, suggestion) = match status {
179                    401 | 403 => (
180                        "Authentication failed",
181                        "Check your API key in ~/.config/mermaid/config.toml",
182                    ),
183                    404 => (
184                        "Model not found",
185                        "Use /model <name> to switch models (auto-pulls if needed), or pull manually with 'ollama pull <name>'",
186                    ),
187                    429 => (
188                        "Rate limited",
189                        "Wait a moment before retrying, or switch to a local model",
190                    ),
191                    500..=599 => (
192                        "Server error",
193                        "The backend service is experiencing issues - try again later",
194                    ),
195                    _ => (
196                        "Request failed",
197                        "Check your network connection and backend configuration",
198                    ),
199                };
200                // Body may be a raw JSON blob from the provider (e.g., Ollama
201                // Cloud emits `{"error":"Internal Server Error (ref: ...)"}`).
202                // Render the extracted message when we can, fall back to the
203                // raw body so we never lose information.
204                let rendered = match try_extract_error_message(message) {
205                    Some(clean) => format!("HTTP {}: {}", status, clean),
206                    None => format!("HTTP {}: {}", status, message),
207                };
208                UserFacingError {
209                    summary: summary.to_string(),
210                    message: rendered,
211                    suggestion: suggestion.to_string(),
212                    // 5xx errors ARE recoverable (the caller can retry) and
213                    // the suggestion tells the user to try again — that's
214                    // the `Temporary` category semantic. `Internal` was
215                    // wrong and painted the status bar with a sterner tone
216                    // than the situation warrants.
217                    category: if *status == 401 || *status == 403 {
218                        ErrorCategory::Auth
219                    } else if *status == 429 || (500..=599).contains(status) {
220                        ErrorCategory::Temporary
221                    } else {
222                        ErrorCategory::Internal
223                    },
224                    recoverable: *status == 429 || *status >= 500,
225                }
226            },
227            ModelError::Backend(BackendError::UnexpectedResponse { backend, message }) => {
228                UserFacingError {
229                    summary: "Unexpected response".to_string(),
230                    message: format!("Received unexpected response from {}: {}", backend, message),
231                    suggestion: "This might be a version mismatch - try updating the backend"
232                        .to_string(),
233                    category: ErrorCategory::Internal,
234                    recoverable: false,
235                }
236            },
237            ModelError::Backend(BackendError::ProviderError {
238                provider,
239                code,
240                message,
241            }) => {
242                let code_str = code.as_deref().unwrap_or("unknown");
243                UserFacingError {
244                    summary: format!("{} error", provider),
245                    message: format!("{} returned error {}: {}", provider, code_str, message),
246                    suggestion: format!(
247                        "Check {} documentation for error code {}",
248                        provider, code_str
249                    ),
250                    category: ErrorCategory::Internal,
251                    recoverable: false,
252                }
253            },
254            ModelError::Config(ConfigError::MissingRequired(field)) => UserFacingError {
255                summary: "Missing configuration".to_string(),
256                message: format!("Required configuration '{}' is missing", field),
257                suggestion: format!("Add '{}' to ~/.config/mermaid/config.toml", field),
258                category: ErrorCategory::Config,
259                recoverable: false,
260            },
261            ModelError::Config(ConfigError::InvalidValue {
262                field,
263                value,
264                reason,
265            }) => UserFacingError {
266                summary: "Invalid configuration".to_string(),
267                message: format!("Invalid value '{}' for '{}': {}", value, field, reason),
268                suggestion: format!("Fix '{}' in ~/.config/mermaid/config.toml", field),
269                category: ErrorCategory::Config,
270                recoverable: false,
271            },
272            ModelError::Config(ConfigError::FileError { path, reason }) => UserFacingError {
273                summary: "Config file error".to_string(),
274                message: format!("Cannot read config file '{}': {}", path, reason),
275                suggestion: "Check file permissions and syntax".to_string(),
276                category: ErrorCategory::Config,
277                recoverable: false,
278            },
279            ModelError::ModelNotFound { model, searched } => UserFacingError {
280                summary: "Model not found".to_string(),
281                message: format!("Model '{}' not found in: {}", model, searched.join(", ")),
282                suggestion: format!(
283                    "Pull the model with 'ollama pull {}' or check if the model name is correct",
284                    model
285                ),
286                category: ErrorCategory::NotFound,
287                recoverable: false,
288            },
289            ModelError::Timeout {
290                operation,
291                duration_secs,
292            } => UserFacingError {
293                summary: "Request timed out".to_string(),
294                message: if *duration_secs == 0 {
295                    format!("'{}' timed out", operation)
296                } else {
297                    format!("'{}' timed out after {} seconds", operation, duration_secs)
298                },
299                suggestion: "The model might be overloaded - try a smaller model or wait and retry"
300                    .to_string(),
301                category: ErrorCategory::Temporary,
302                recoverable: true,
303            },
304            ModelError::RateLimit { retry_after } => {
305                let wait_msg = retry_after
306                    .map(|s| format!("Wait {} seconds", s))
307                    .unwrap_or_else(|| "Wait a moment".to_string());
308                UserFacingError {
309                    summary: "Rate limited".to_string(),
310                    message: "Too many requests - rate limit exceeded".to_string(),
311                    suggestion: format!(
312                        "{}. Consider using a local Ollama model to avoid rate limits",
313                        wait_msg
314                    ),
315                    category: ErrorCategory::Temporary,
316                    recoverable: true,
317                }
318            },
319            ModelError::InvalidRequest(msg) => UserFacingError {
320                summary: "Invalid request".to_string(),
321                message: format!("The request was invalid: {}", msg),
322                suggestion: "Check your message format or try rephrasing".to_string(),
323                category: ErrorCategory::Internal,
324                recoverable: false,
325            },
326            ModelError::ParseError { message, .. } => UserFacingError {
327                summary: "Parse error".to_string(),
328                message: format!("Failed to parse response: {}", message),
329                suggestion:
330                    "The model returned an unexpected format - try sending the message again"
331                        .to_string(),
332                category: ErrorCategory::Internal,
333                recoverable: true,
334            },
335            ModelError::StreamError(msg) => UserFacingError {
336                summary: "Stream interrupted".to_string(),
337                message: format!("Connection lost during streaming: {}", msg),
338                suggestion: "Check your network connection and try again".to_string(),
339                category: ErrorCategory::Connection,
340                recoverable: true,
341            },
342            ModelError::Authentication(msg) => UserFacingError {
343                summary: "Authentication failed".to_string(),
344                message: format!("Authentication error: {}", msg),
345                suggestion:
346                    "Check your API key in ~/.config/mermaid/config.toml or environment variables"
347                        .to_string(),
348                category: ErrorCategory::Auth,
349                recoverable: false,
350            },
351            ModelError::Unsupported { feature } => UserFacingError {
352                summary: "Unsupported feature".to_string(),
353                message: format!("The current model adapter does not support '{}'.", feature),
354                suggestion: format!(
355                    "Switch to a provider/model that supports '{}', or omit this operation.",
356                    feature
357                ),
358                category: ErrorCategory::Internal,
359                recoverable: false,
360            },
361            ModelError::Cancelled => UserFacingError {
362                summary: "Cancelled".to_string(),
363                message: "The request was cancelled.".to_string(),
364                suggestion: String::new(),
365                category: ErrorCategory::Temporary,
366                recoverable: true,
367            },
368        }
369    }
370}
371
372/// Backend-specific errors
373#[derive(Debug)]
374pub enum BackendError {
375    /// Connection failed (network, DNS, etc)
376    ConnectionFailed {
377        backend: String,
378        url: String,
379        reason: String,
380    },
381
382    /// Backend not available (not running, health check failed)
383    NotAvailable { backend: String, reason: String },
384
385    /// HTTP error from backend
386    HttpError { status: u16, message: String },
387
388    /// Backend returned unexpected response format
389    UnexpectedResponse { backend: String, message: String },
390
391    /// Provider-specific error
392    ProviderError {
393        provider: String,
394        code: Option<String>,
395        message: String,
396    },
397}
398
399impl fmt::Display for BackendError {
400    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
401        match self {
402            BackendError::ConnectionFailed {
403                backend,
404                url,
405                reason,
406            } => {
407                write!(f, "Failed to connect to {} at {}: {}", backend, url, reason)
408            },
409            BackendError::NotAvailable { backend, reason } => {
410                write!(f, "Backend '{}' not available: {}", backend, reason)
411            },
412            BackendError::HttpError { status, message } => {
413                write!(f, "HTTP error {}: {}", status, message)
414            },
415            BackendError::UnexpectedResponse { backend, message } => {
416                write!(f, "Unexpected response from {}: {}", backend, message)
417            },
418            BackendError::ProviderError {
419                provider,
420                code,
421                message,
422            } => {
423                if let Some(c) = code {
424                    write!(f, "{} error {}: {}", provider, c, message)
425                } else {
426                    write!(f, "{} error: {}", provider, message)
427                }
428            },
429        }
430    }
431}
432
433impl std::error::Error for BackendError {}
434
435/// Configuration errors
436#[derive(Debug)]
437pub enum ConfigError {
438    /// Missing required configuration
439    MissingRequired(String),
440
441    /// Invalid value for configuration
442    InvalidValue {
443        field: String,
444        value: String,
445        reason: String,
446    },
447
448    /// File operation error (read, parse, etc)
449    FileError { path: String, reason: String },
450}
451
452impl fmt::Display for ConfigError {
453    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
454        match self {
455            ConfigError::MissingRequired(field) => {
456                write!(f, "Missing required configuration: {}", field)
457            },
458            ConfigError::InvalidValue {
459                field,
460                value,
461                reason,
462            } => {
463                write!(f, "Invalid value for '{}': '{}' ({})", field, value, reason)
464            },
465            ConfigError::FileError { path, reason } => {
466                write!(f, "Error reading config file '{}': {}", path, reason)
467            },
468        }
469    }
470}
471
472impl std::error::Error for ConfigError {}
473
474/// Result type alias for model operations
475pub type Result<T> = std::result::Result<T, ModelError>;
476
477/// Conversion from anyhow::Error (for gradual migration)
478impl From<anyhow::Error> for ModelError {
479    fn from(err: anyhow::Error) -> Self {
480        ModelError::InvalidRequest(err.to_string())
481    }
482}
483
484/// Conversion from reqwest::Error
485impl From<reqwest::Error> for ModelError {
486    fn from(err: reqwest::Error) -> Self {
487        if err.is_timeout() {
488            // reqwest::Error doesn't expose the actual elapsed duration,
489            // and the adapter only sets a connect_timeout (no global
490            // request timeout), so there is no truthful number to report.
491            // 0 is a sentinel meaning "unknown" — the Display and
492            // to_user_facing impls for ModelError::Timeout omit the
493            // "after N seconds" suffix when duration_secs == 0.
494            ModelError::Timeout {
495                operation: "HTTP request".to_string(),
496                duration_secs: 0,
497            }
498        } else if err.is_connect() {
499            ModelError::Backend(BackendError::ConnectionFailed {
500                backend: "unknown".to_string(),
501                url: err
502                    .url()
503                    .map(|u| u.to_string())
504                    .unwrap_or_else(|| "unknown".to_string()),
505                reason: err.to_string(),
506            })
507        } else if err.is_status() {
508            let status = err.status().map(|s| s.as_u16()).unwrap_or(500);
509            ModelError::Backend(BackendError::HttpError {
510                status,
511                message: err.to_string(),
512            })
513        } else {
514            ModelError::Backend(BackendError::UnexpectedResponse {
515                backend: "unknown".to_string(),
516                message: err.to_string(),
517            })
518        }
519    }
520}
521
522/// Conversion from serde_json::Error
523impl From<serde_json::Error> for ModelError {
524    fn from(err: serde_json::Error) -> Self {
525        ModelError::ParseError {
526            message: err.to_string(),
527            raw: None,
528        }
529    }
530}
531
532/// Try to extract a human-readable error message from a raw upstream
533/// response body. Handles the two shapes observed in the wild across
534/// Ollama, OpenAI, Groq, OpenRouter, Cerebras, DeepInfra, Together
535/// (Anthropic + Gemini have their own adapter-level parsers):
536///
537/// - `{"error": "some string"}` — Ollama Cloud style
538/// - `{"error": {"message": "...", ...}}` — OpenAI Chat Completions style
539///
540/// Returns `None` when the body isn't parseable JSON or doesn't match
541/// either shape — callers fall back to the raw body so no information
542/// is lost.
543fn try_extract_error_message(body: &str) -> Option<String> {
544    let trimmed = body.trim();
545    if !trimmed.starts_with('{') {
546        return None;
547    }
548    let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
549    let error = value.get("error")?;
550
551    // Shape 1: `error` is a plain string.
552    if let Some(s) = error.as_str() {
553        return Some(s.trim().to_string());
554    }
555
556    // Shape 2: `error` is an object with a `message` field. Prepend
557    // `type:` if present (matches OpenAI's `"invalid_request_error"` +
558    // message convention).
559    if let Some(obj) = error.as_object() {
560        let message = obj.get("message").and_then(|v| v.as_str())?;
561        let kind = obj
562            .get("type")
563            .and_then(|v| v.as_str())
564            .or_else(|| obj.get("code").and_then(|v| v.as_str()));
565        let out = match kind {
566            Some(k) if !k.is_empty() => format!("{}: {}", k, message),
567            _ => message.to_string(),
568        };
569        return Some(out.trim().to_string());
570    }
571
572    None
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    #[test]
580    fn timeout_display_omits_zero_duration() {
581        let err = ModelError::Timeout {
582            operation: "HTTP request".to_string(),
583            duration_secs: 0,
584        };
585        let rendered = err.to_string();
586        assert_eq!(rendered, "Operation 'HTTP request' timed out");
587        assert!(!rendered.contains("0 seconds"));
588    }
589
590    #[test]
591    fn timeout_display_shows_nonzero_duration() {
592        let err = ModelError::Timeout {
593            operation: "HTTP request".to_string(),
594            duration_secs: 45,
595        };
596        let rendered = err.to_string();
597        assert_eq!(
598            rendered,
599            "Operation 'HTTP request' timed out after 45 seconds"
600        );
601    }
602
603    #[test]
604    fn timeout_user_facing_omits_zero_duration() {
605        let err = ModelError::Timeout {
606            operation: "HTTP request".to_string(),
607            duration_secs: 0,
608        };
609        let ufe = err.to_user_facing();
610        assert_eq!(ufe.message, "'HTTP request' timed out");
611        assert!(!ufe.message.contains("0 seconds"));
612    }
613
614    #[test]
615    fn extract_error_handles_ollama_string_shape() {
616        let body = r#"{"error":"Internal Server Error (ref: 6e8ae4c7)"}"#;
617        assert_eq!(
618            try_extract_error_message(body).as_deref(),
619            Some("Internal Server Error (ref: 6e8ae4c7)")
620        );
621    }
622
623    #[test]
624    fn extract_error_handles_openai_object_shape_with_type() {
625        let body = r#"{"error":{"message":"Rate limit","type":"rate_limit_error","code":null}}"#;
626        assert_eq!(
627            try_extract_error_message(body).as_deref(),
628            Some("rate_limit_error: Rate limit")
629        );
630    }
631
632    /// OpenRouter emits `code` as a numeric HTTP status, not a string.
633    /// `as_str()` returns None so we skip the prefix gracefully.
634    #[test]
635    fn extract_error_handles_openrouter_numeric_code() {
636        let body = r#"{"error":{"message":"upstream timeout","code":504,"metadata":{}}}"#;
637        assert_eq!(
638            try_extract_error_message(body).as_deref(),
639            Some("upstream timeout")
640        );
641    }
642
643    #[test]
644    fn extract_error_returns_none_for_non_json() {
645        assert_eq!(try_extract_error_message("<html>bad gateway</html>"), None);
646        assert_eq!(try_extract_error_message(""), None);
647        assert_eq!(try_extract_error_message("plain text error"), None);
648    }
649
650    #[test]
651    fn extract_error_returns_none_for_missing_error_field() {
652        let body = r#"{"status":"ok","message":"nothing here"}"#;
653        assert_eq!(try_extract_error_message(body), None);
654    }
655
656    /// 5xx responses carrying an Ollama-style JSON body should render as
657    /// the clean string in the user-facing message, and be categorised as
658    /// `Temporary` (matches `recoverable: true`) so the status bar treats
659    /// them as "come back and retry" rather than "something is broken".
660    #[test]
661    fn http_500_renders_clean_message_and_temporary_category() {
662        let err = ModelError::Backend(BackendError::HttpError {
663            status: 500,
664            message: r#"{"error":"Internal Server Error (ref: abc-123)"}"#.to_string(),
665        });
666        let ufe = err.to_user_facing();
667        assert_eq!(ufe.summary, "Server error");
668        assert_eq!(
669            ufe.message,
670            "HTTP 500: Internal Server Error (ref: abc-123)"
671        );
672        assert!(ufe.recoverable);
673        assert_eq!(ufe.category, ErrorCategory::Temporary);
674    }
675
676    /// Unparseable bodies fall back to the raw content so we never lose
677    /// information.
678    #[test]
679    fn http_500_falls_back_to_raw_body_for_html() {
680        let err = ModelError::Backend(BackendError::HttpError {
681            status: 502,
682            message: "<html>Bad Gateway</html>".to_string(),
683        });
684        let ufe = err.to_user_facing();
685        assert_eq!(ufe.message, "HTTP 502: <html>Bad Gateway</html>");
686    }
687}