1use actix_web::{http::StatusCode, HttpResponse, ResponseError};
19use serde::Serialize;
20use thiserror::Error;
21
22pub type Result<T, E = AppError> = std::result::Result<T, E>;
24
25#[derive(Debug, Error)]
27pub enum AppError {
28 #[error("Bad request: {0}")]
29 BadRequest(String),
30
31 #[error("Unauthorized: {0}")]
32 Unauthorized(String),
33
34 #[error("Forbidden: {0}")]
35 Forbidden(String),
36
37 #[error("Tool '{0}' not found")]
38 ToolNotFound(String),
39
40 #[error("Tool execution failed: {0}")]
41 ToolExecutionError(String),
42
43 #[error("Tool requires approval: {0}")]
44 ToolApprovalRequired(String),
45
46 #[error("{0} not found")]
47 NotFound(String),
48
49 #[error("Proxy authentication required")]
50 ProxyAuthRequired,
51
52 #[error("Config recovery pending confirmation: {0}")]
56 ConfigRecoveryPending(String),
57
58 #[error("Configuration revision conflict: expected {expected}, actual {actual}")]
59 ConfigConflict { expected: u64, actual: u64 },
60
61 #[error("Internal server error: {0}")]
62 InternalError(#[from] anyhow::Error),
63
64 #[error("Storage error: {0}")]
65 StorageError(#[from] std::io::Error),
66
67 #[error("Serialization error: {0}")]
68 SerializationError(#[from] serde_json::Error),
69}
70
71#[derive(Serialize)]
72struct JsonError {
73 message: String,
74 r#type: String,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 code: Option<String>,
77}
78
79#[derive(Serialize)]
80struct JsonErrorWrapper {
81 error: JsonError,
82}
83
84pub fn error_value(message: impl Into<String>) -> serde_json::Value {
105 serde_json::json!({ "message": message.into(), "type": "api_error" })
106}
107
108pub fn json_error(status: StatusCode, message: impl Into<String>) -> HttpResponse {
112 json_error_with_code(status, message.into(), None)
113}
114
115pub fn json_internal_server_error(message: impl Into<String>) -> actix_web::Error {
119 let message = message.into();
120 let response = json_error(StatusCode::INTERNAL_SERVER_ERROR, message.clone());
121 actix_web::error::InternalError::from_response(message, response).into()
122}
123
124fn json_error_with_code(status: StatusCode, message: String, code: Option<&str>) -> HttpResponse {
125 HttpResponse::build(status).json(JsonErrorWrapper {
126 error: JsonError {
127 message,
128 r#type: "api_error".to_string(),
129 code: code.map(str::to_string),
130 },
131 })
132}
133
134impl ResponseError for AppError {
135 fn status_code(&self) -> StatusCode {
136 match self {
137 AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
138 AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
139 AppError::Forbidden(_) => StatusCode::FORBIDDEN,
140 AppError::ToolNotFound(_) => StatusCode::NOT_FOUND,
141 AppError::ToolExecutionError(_) => StatusCode::BAD_REQUEST,
142 AppError::ToolApprovalRequired(_) => StatusCode::FORBIDDEN,
143 AppError::NotFound(_) => StatusCode::NOT_FOUND,
144 AppError::ProxyAuthRequired => StatusCode::PRECONDITION_REQUIRED,
145 AppError::ConfigRecoveryPending(_) => StatusCode::CONFLICT,
146 AppError::ConfigConflict { .. } => StatusCode::CONFLICT,
147 AppError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
148 AppError::StorageError(_) => StatusCode::INTERNAL_SERVER_ERROR,
149 AppError::SerializationError(_) => StatusCode::INTERNAL_SERVER_ERROR,
150 }
151 }
152
153 fn error_response(&self) -> HttpResponse {
154 let status_code = self.status_code();
155 let code = match self {
156 AppError::ProxyAuthRequired => Some("proxy_auth_required"),
157 AppError::ConfigRecoveryPending(_) => Some("config_recovery_pending"),
158 AppError::ConfigConflict { .. } => Some("config_revision_conflict"),
159 _ => None,
160 };
161 json_error_with_code(status_code, self.to_string(), code)
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn test_app_error_bad_request() {
171 let err = AppError::BadRequest("Invalid input".to_string());
172 assert_eq!(err.to_string(), "Bad request: Invalid input");
173 assert_eq!(err.status_code(), StatusCode::BAD_REQUEST);
174 }
175
176 #[test]
177 fn test_app_error_tool_not_found() {
178 let err = AppError::ToolNotFound("bash".to_string());
179 assert_eq!(err.to_string(), "Tool 'bash' not found");
180 assert_eq!(err.status_code(), StatusCode::NOT_FOUND);
181 }
182
183 #[test]
184 fn test_app_error_tool_execution_error() {
185 let err = AppError::ToolExecutionError("Command failed".to_string());
186 assert_eq!(err.to_string(), "Tool execution failed: Command failed");
187 assert_eq!(err.status_code(), StatusCode::BAD_REQUEST);
188 }
189
190 #[test]
191 fn test_app_error_tool_approval_required() {
192 let err = AppError::ToolApprovalRequired("dangerous_tool".to_string());
193 assert_eq!(err.to_string(), "Tool requires approval: dangerous_tool");
194 assert_eq!(err.status_code(), StatusCode::FORBIDDEN);
195 }
196
197 #[test]
198 fn test_app_error_not_found() {
199 let err = AppError::NotFound("Session".to_string());
200 assert_eq!(err.to_string(), "Session not found");
201 assert_eq!(err.status_code(), StatusCode::NOT_FOUND);
202 }
203
204 #[test]
205 fn test_app_error_proxy_auth_required() {
206 let err = AppError::ProxyAuthRequired;
207 assert_eq!(err.to_string(), "Proxy authentication required");
208 assert_eq!(err.status_code(), StatusCode::PRECONDITION_REQUIRED);
209 }
210
211 #[test]
212 fn test_app_error_internal_error() {
213 let err = AppError::InternalError(anyhow::anyhow!("Something went wrong"));
214 assert!(err.to_string().contains("Something went wrong"));
215 assert_eq!(err.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
216 }
217
218 #[test]
219 fn test_app_error_storage_error() {
220 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
221 let err = AppError::StorageError(io_err);
222 assert!(err.to_string().contains("file not found"));
223 assert_eq!(err.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
224 }
225
226 #[test]
227 fn test_app_error_serialization_error() {
228 let json_err = serde_json::from_str::<i32>("invalid").unwrap_err();
229 let err = AppError::SerializationError(json_err);
230 assert!(err.to_string().contains("Serialization error"));
231 assert_eq!(err.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
232 }
233
234 #[test]
235 fn test_error_response_bad_request() {
236 let err = AppError::BadRequest("Test error".to_string());
237 let response = err.error_response();
238 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
239 }
240
241 #[test]
242 fn test_error_response_tool_not_found() {
243 let err = AppError::ToolNotFound("tool".to_string());
244 let response = err.error_response();
245 assert_eq!(response.status(), StatusCode::NOT_FOUND);
246 }
247
248 #[test]
249 fn test_error_response_proxy_auth_includes_code() {
250 let err = AppError::ProxyAuthRequired;
251 let response = err.error_response();
252 assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);
253 }
254
255 #[test]
256 fn test_app_error_debug() {
257 let err = AppError::BadRequest("test".to_string());
258 let debug_str = format!("{:?}", err);
259 assert!(debug_str.contains("BadRequest"));
260 }
261
262 #[test]
263 fn test_app_error_clone() {
264 let err1 = AppError::BadRequest("test".to_string());
265 let debug_output = format!("{:?}", err1);
268 assert!(!debug_output.is_empty());
269 }
270
271 #[test]
272 fn test_result_type_ok() {
273 let result: Result<i32> = Ok(42);
274 assert!(result.is_ok());
275 assert!(matches!(result, Ok(42)));
276 }
277
278 #[test]
279 fn test_result_type_err() {
280 let result: Result<i32> = Err(AppError::BadRequest("error".to_string()));
281 assert!(result.is_err());
282 }
283
284 #[test]
285 fn test_internal_error_from_anyhow() {
286 let anyhow_err = anyhow::anyhow!("Test error");
287 let app_error: AppError = anyhow_err.into();
288 assert!(matches!(app_error, AppError::InternalError(_)));
289 }
290
291 #[test]
292 fn test_storage_error_from_io() {
293 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
294 let app_error: AppError = io_err.into();
295 assert!(matches!(app_error, AppError::StorageError(_)));
296 }
297
298 #[test]
299 fn test_serialization_error_from_serde_json() {
300 let json_err = serde_json::from_str::<bool>("not a bool").unwrap_err();
301 let app_error: AppError = json_err.into();
302 assert!(matches!(app_error, AppError::SerializationError(_)));
303 }
304
305 #[actix_web::test]
310 async fn app_error_and_error_value_agree_on_envelope_shape() {
311 let resp = AppError::NotFound("Session".to_string()).error_response();
312 let bytes = actix_web::body::to_bytes(resp.into_body()).await.unwrap();
313 let app_err_body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
314 assert_eq!(app_err_body["error"]["type"], "api_error");
315 assert!(app_err_body["error"]["message"].is_string());
316
317 let helper_error = error_value("Session not found");
318 assert_eq!(helper_error["type"], "api_error");
319 assert_eq!(helper_error["message"], "Session not found");
320
321 assert_eq!(
323 app_err_body["error"]["type"], helper_error["type"],
324 "AppError and error_value must use the same error \"type\" tag"
325 );
326 }
327
328 #[actix_web::test]
329 async fn shared_json_error_helpers_use_the_canonical_envelope() {
330 let response = json_error(StatusCode::TOO_MANY_REQUESTS, "slow down");
331 assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
332 let bytes = actix_web::body::to_bytes(response.into_body())
333 .await
334 .unwrap();
335 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
336 assert_eq!(body["error"]["message"], "slow down");
337 assert_eq!(body["error"]["type"], "api_error");
338
339 let error = json_internal_server_error("storage failed");
340 let response = error.error_response();
341 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
342 let bytes = actix_web::body::to_bytes(response.into_body())
343 .await
344 .unwrap();
345 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
346 assert_eq!(body["error"]["message"], "storage failed");
347 assert_eq!(body["error"]["type"], "api_error");
348 }
349
350 #[test]
356 fn native_http_error_sources_use_the_canonical_envelope() {
357 let source_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
358 let flat_error = regex::Regex::new(r#""error"\s*:\s*([^,}\n]+)"#).unwrap();
359 let actix_convenience_error = regex::Regex::new(
360 r"\bError(?:BadRequest|Unauthorized|Forbidden|NotFound|MethodNotAllowed|NotAcceptable|RequestTimeout|Conflict|Gone|PreconditionFailed|ExpectationFailed|PayloadTooLarge|UnsupportedMediaType|UnprocessableEntity|TooManyRequests|InternalServerError|NotImplemented|BadGateway|ServiceUnavailable|GatewayTimeout)\s*\(",
361 )
362 .unwrap();
363 let mut violations = Vec::new();
364
365 for entry in walkdir::WalkDir::new(source_root.join("handlers"))
366 .into_iter()
367 .filter_map(Result::ok)
368 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "rs"))
369 {
370 let relative = entry.path().strip_prefix(&source_root).unwrap();
371 let relative = relative.to_string_lossy().replace('\\', "/");
372 if relative.starts_with("handlers/openai/")
373 || relative.starts_with("handlers/anthropic/")
374 || relative.starts_with("handlers/gemini/")
375 || relative.starts_with("handlers/agent/ws_v2/")
376 {
377 continue;
378 }
379 inspect_native_error_source(
380 entry.path(),
381 &relative,
382 &flat_error,
383 &actix_convenience_error,
384 &mut violations,
385 );
386 }
387 inspect_native_error_source(
388 &source_root.join("rate_limit.rs"),
389 "rate_limit.rs",
390 &flat_error,
391 &actix_convenience_error,
392 &mut violations,
393 );
394
395 assert!(
396 violations.is_empty(),
397 "native HTTP errors must use AppError/error_value/json_error; vendor SSE and WS frames are excluded:\n{}",
398 violations.join("\n")
399 );
400 }
401
402 fn inspect_native_error_source(
403 path: &std::path::Path,
404 relative: &str,
405 flat_error: ®ex::Regex,
406 actix_convenience_error: ®ex::Regex,
407 violations: &mut Vec<String>,
408 ) {
409 let source = std::fs::read_to_string(path).unwrap();
410 for capture in flat_error.captures_iter(&source) {
411 let matched = capture.get(0).unwrap();
412 let line_number = source[..matched.start()]
413 .bytes()
414 .filter(|byte| *byte == b'\n')
415 .count()
416 + 1;
417 let line_start = source[..matched.start()]
418 .rfind('\n')
419 .map_or(0, |position| position + 1);
420 let line_end = source[matched.start()..]
421 .find('\n')
422 .map_or(source.len(), |position| matched.start() + position);
423 if source[line_start..line_end].trim_start().starts_with("//") {
424 continue;
425 }
426 let value = capture.get(1).unwrap().as_str().trim_start();
427 if !value.starts_with("crate::error::error_value(")
428 && !value.starts_with("error_value(")
429 && !value.starts_with('{')
430 {
431 violations.push(format!(
432 "{relative}:{line_number}: flat error value starts with `{value}`"
433 ));
434 }
435 }
436 for matched in actix_convenience_error.find_iter(&source) {
437 let line_number = source[..matched.start()]
438 .bytes()
439 .filter(|byte| *byte == b'\n')
440 .count()
441 + 1;
442 violations.push(format!(
443 "{relative}:{line_number}: Actix convenience error renders text/plain"
444 ));
445 }
446 }
447}