starweaver_model/adapter/
error.rs1use serde_json::Value;
2use thiserror::Error;
3
4#[derive(Debug, Error)]
6pub enum ModelError {
7 #[error("message mapping failed: {0}")]
9 MessageMapping(String),
10 #[error("response parsing failed: {0}")]
12 ResponseParsing(String),
13 #[error("transport failed: {0}")]
15 Transport(String),
16 #[error("real model request blocked for {url}")]
18 RealModelRequestBlocked {
19 url: String,
21 },
22 #[error("provider status {status}: {body}")]
24 ProviderStatus {
25 status: u16,
27 body: Value,
29 retryable: bool,
31 },
32 #[error("retry attempts exhausted after {attempts} attempts: {source}")]
34 RetryExhausted {
35 attempts: u32,
37 source: Box<Self>,
39 },
40 #[error("model request cancelled: {reason}")]
42 Cancelled {
43 reason: String,
45 },
46 #[error("unsupported provider response: {0}")]
48 UnsupportedResponse(String),
49}
50
51impl ModelError {
52 #[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}