Skip to main content

starweaver_model/adapter/
error.rs

1use serde_json::Value;
2use thiserror::Error;
3
4/// Model adapter error.
5#[derive(Debug, Error)]
6pub enum ModelError {
7    /// Canonical history cannot be mapped into a provider request.
8    #[error("message mapping failed: {0}")]
9    MessageMapping(String),
10    /// Provider response cannot be parsed into canonical response.
11    #[error("response parsing failed: {0}")]
12    ResponseParsing(String),
13    /// Transport failed.
14    #[error("transport failed: {0}")]
15    Transport(String),
16    /// A real HTTP model request was blocked by the global test guard.
17    #[error("real model request blocked for {url}")]
18    RealModelRequestBlocked {
19        /// Target request URL.
20        url: String,
21    },
22    /// Provider returned a non-success status.
23    #[error("provider status {status}: {body}")]
24    ProviderStatus {
25        /// HTTP status code.
26        status: u16,
27        /// Provider response body.
28        body: Value,
29        /// Whether retry policy may retry this status.
30        retryable: bool,
31    },
32    /// Retry attempts were exhausted.
33    #[error("retry attempts exhausted after {attempts} attempts: {source}")]
34    RetryExhausted {
35        /// Attempt count.
36        attempts: u32,
37        /// Last error.
38        source: Box<Self>,
39    },
40    /// Request or stream was cancelled by the runtime.
41    #[error("model request cancelled: {reason}")]
42    Cancelled {
43        /// Cancellation reason.
44        reason: String,
45    },
46    /// Provider returned an unsupported response shape.
47    #[error("unsupported provider response: {0}")]
48    UnsupportedResponse(String),
49}
50
51impl ModelError {
52    /// Return an error message safe for durable events and client-visible streams.
53    ///
54    /// Provider status bodies remain available on the typed error for retry and
55    /// diagnostic classification, but are omitted because they can contain
56    /// provider-echoed request content, account details, or credentials.
57    #[must_use]
58    pub fn public_message(&self) -> String {
59        match self {
60            Self::ProviderStatus { status, .. } => format!("provider status {status}"),
61            Self::RetryExhausted { attempts, source } => format!(
62                "retry attempts exhausted after {attempts} attempts: {}",
63                source.public_message()
64            ),
65            Self::MessageMapping(_) => "model request could not be constructed".to_string(),
66            Self::ResponseParsing(_) => "provider response could not be parsed".to_string(),
67            Self::Transport(_) => "model transport failed".to_string(),
68            Self::RealModelRequestBlocked { .. } => "real model request blocked".to_string(),
69            Self::Cancelled { .. } => "model request cancelled".to_string(),
70            Self::UnsupportedResponse(_) => "provider returned an unsupported response".to_string(),
71        }
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use serde_json::json;
78
79    use super::ModelError;
80
81    #[test]
82    fn public_provider_status_message_omits_response_body() {
83        let error = ModelError::ProviderStatus {
84            status: 401,
85            body: json!({
86                "error": "unauthorized",
87                "echoed_token": "provider-secret"
88            }),
89            retryable: false,
90        };
91
92        assert_eq!(error.public_message(), "provider status 401");
93        assert!(!error.public_message().contains("provider-secret"));
94        assert!(error.to_string().contains("provider-secret"));
95    }
96
97    #[test]
98    fn public_messages_redact_all_free_form_diagnostics() {
99        let secret = "provider-secret";
100        let cases = [
101            (
102                ModelError::MessageMapping(secret.to_string()),
103                "model request could not be constructed",
104            ),
105            (
106                ModelError::ResponseParsing(secret.to_string()),
107                "provider response could not be parsed",
108            ),
109            (
110                ModelError::Transport(format!(
111                    "Authorization: Bearer {secret}; https://example.test?api_key={secret}"
112                )),
113                "model transport failed",
114            ),
115            (
116                ModelError::RealModelRequestBlocked {
117                    url: format!("https://example.test?api_key={secret}"),
118                },
119                "real model request blocked",
120            ),
121            (
122                ModelError::Cancelled {
123                    reason: secret.to_string(),
124                },
125                "model request cancelled",
126            ),
127            (
128                ModelError::UnsupportedResponse(secret.to_string()),
129                "provider returned an unsupported response",
130            ),
131        ];
132
133        for (error, expected) in cases {
134            assert_eq!(error.public_message(), expected);
135            assert!(!error.public_message().contains(secret));
136        }
137    }
138
139    #[test]
140    fn public_retry_exhausted_message_redacts_nested_provider_body() {
141        let error = ModelError::RetryExhausted {
142            attempts: 3,
143            source: Box::new(ModelError::ProviderStatus {
144                status: 429,
145                body: json!({"secret": "provider-secret"}),
146                retryable: true,
147            }),
148        };
149
150        assert_eq!(
151            error.public_message(),
152            "retry attempts exhausted after 3 attempts: provider status 429"
153        );
154        assert!(!error.public_message().contains("provider-secret"));
155    }
156}