Skip to main content

bamboo_server/
error.rs

1//! Server error types and HTTP response handling
2//!
3//! This module provides a unified error handling system for the Actix-web server.
4//! All errors are converted to HTTP responses with appropriate status codes.
5//!
6//! # Error Types
7//!
8//! - `BadRequest`: Client errors (400)
9//! - `ToolNotFound`: Tool not available (404)
10//! - `ToolExecutionError`: Tool execution failed (400)
11//! - `ToolApprovalRequired`: Tool needs user approval (403)
12//! - `NotFound`: Resource not found (404)
13//! - `ProxyAuthRequired`: Proxy authentication needed (428)
14//! - `InternalError`: Server errors (500)
15//! - `StorageError`: File system errors (500)
16//! - `SerializationError`: JSON serialization errors (500)
17
18use actix_web::{http::StatusCode, HttpResponse, ResponseError};
19use serde::Serialize;
20use thiserror::Error;
21
22/// Result type alias for server operations
23pub type Result<T, E = AppError> = std::result::Result<T, E>;
24
25/// Application error enum with HTTP status code mapping
26#[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    /// config.json was recovered from a corrupt file (#153) and the recovery
53    /// hasn't been confirmed yet — writes are refused until the caller
54    /// confirms (or rejects) via the recovery-confirm API.
55    #[error("Config recovery pending confirmation: {0}")]
56    ConfigRecoveryPending(String),
57
58    #[error("Internal server error: {0}")]
59    InternalError(#[from] anyhow::Error),
60
61    #[error("Storage error: {0}")]
62    StorageError(#[from] std::io::Error),
63
64    #[error("Serialization error: {0}")]
65    SerializationError(#[from] serde_json::Error),
66}
67
68#[derive(Serialize)]
69struct JsonError {
70    message: String,
71    r#type: String,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    code: Option<String>,
74}
75
76#[derive(Serialize)]
77struct JsonErrorWrapper {
78    error: JsonError,
79}
80
81/// The value of the `"error"` key in bamboo's canonical error envelope
82/// (`{"error": {"message", "type", "code"}}`, matching [`AppError`]'s
83/// [`ResponseError::error_response`] body) — for the minority of handlers
84/// that are not (yet) modeled as an `AppError` variant but still need to
85/// return extra sibling fields alongside the error (e.g. `session_id`,
86/// `message_id`) that a bare `AppError` has no place for.
87///
88/// Prefer returning `Result<_, AppError>` directly when there are no extra
89/// sibling fields — this helper exists only to converge the remaining
90/// hand-written `HttpResponse::X().json(json!({"error": "<flat string>", ...}))`
91/// call sites (#251 finding 2) onto the SAME envelope shape as `AppError`,
92/// without forcing every one of them through a full `AppError` variant.
93///
94/// # Example
95/// ```ignore
96/// HttpResponse::NotFound().json(serde_json::json!({
97///     "error": error_value("Session not found"),
98///     "session_id": session_id,
99/// }))
100/// ```
101pub fn error_value(message: impl Into<String>) -> serde_json::Value {
102    serde_json::json!({ "message": message.into(), "type": "api_error" })
103}
104
105impl ResponseError for AppError {
106    fn status_code(&self) -> StatusCode {
107        match self {
108            AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
109            AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
110            AppError::Forbidden(_) => StatusCode::FORBIDDEN,
111            AppError::ToolNotFound(_) => StatusCode::NOT_FOUND,
112            AppError::ToolExecutionError(_) => StatusCode::BAD_REQUEST,
113            AppError::ToolApprovalRequired(_) => StatusCode::FORBIDDEN,
114            AppError::NotFound(_) => StatusCode::NOT_FOUND,
115            AppError::ProxyAuthRequired => StatusCode::PRECONDITION_REQUIRED,
116            AppError::ConfigRecoveryPending(_) => StatusCode::CONFLICT,
117            AppError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
118            AppError::StorageError(_) => StatusCode::INTERNAL_SERVER_ERROR,
119            AppError::SerializationError(_) => StatusCode::INTERNAL_SERVER_ERROR,
120        }
121    }
122
123    fn error_response(&self) -> HttpResponse {
124        let status_code = self.status_code();
125        let error_response = JsonErrorWrapper {
126            error: JsonError {
127                message: self.to_string(),
128                r#type: "api_error".to_string(),
129                code: match self {
130                    AppError::ProxyAuthRequired => Some("proxy_auth_required".to_string()),
131                    AppError::ConfigRecoveryPending(_) => {
132                        Some("config_recovery_pending".to_string())
133                    }
134                    _ => None,
135                },
136            },
137        };
138        HttpResponse::build(status_code).json(error_response)
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_app_error_bad_request() {
148        let err = AppError::BadRequest("Invalid input".to_string());
149        assert_eq!(err.to_string(), "Bad request: Invalid input");
150        assert_eq!(err.status_code(), StatusCode::BAD_REQUEST);
151    }
152
153    #[test]
154    fn test_app_error_tool_not_found() {
155        let err = AppError::ToolNotFound("bash".to_string());
156        assert_eq!(err.to_string(), "Tool 'bash' not found");
157        assert_eq!(err.status_code(), StatusCode::NOT_FOUND);
158    }
159
160    #[test]
161    fn test_app_error_tool_execution_error() {
162        let err = AppError::ToolExecutionError("Command failed".to_string());
163        assert_eq!(err.to_string(), "Tool execution failed: Command failed");
164        assert_eq!(err.status_code(), StatusCode::BAD_REQUEST);
165    }
166
167    #[test]
168    fn test_app_error_tool_approval_required() {
169        let err = AppError::ToolApprovalRequired("dangerous_tool".to_string());
170        assert_eq!(err.to_string(), "Tool requires approval: dangerous_tool");
171        assert_eq!(err.status_code(), StatusCode::FORBIDDEN);
172    }
173
174    #[test]
175    fn test_app_error_not_found() {
176        let err = AppError::NotFound("Session".to_string());
177        assert_eq!(err.to_string(), "Session not found");
178        assert_eq!(err.status_code(), StatusCode::NOT_FOUND);
179    }
180
181    #[test]
182    fn test_app_error_proxy_auth_required() {
183        let err = AppError::ProxyAuthRequired;
184        assert_eq!(err.to_string(), "Proxy authentication required");
185        assert_eq!(err.status_code(), StatusCode::PRECONDITION_REQUIRED);
186    }
187
188    #[test]
189    fn test_app_error_internal_error() {
190        let err = AppError::InternalError(anyhow::anyhow!("Something went wrong"));
191        assert!(err.to_string().contains("Something went wrong"));
192        assert_eq!(err.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
193    }
194
195    #[test]
196    fn test_app_error_storage_error() {
197        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
198        let err = AppError::StorageError(io_err);
199        assert!(err.to_string().contains("file not found"));
200        assert_eq!(err.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
201    }
202
203    #[test]
204    fn test_app_error_serialization_error() {
205        let json_err = serde_json::from_str::<i32>("invalid").unwrap_err();
206        let err = AppError::SerializationError(json_err);
207        assert!(err.to_string().contains("Serialization error"));
208        assert_eq!(err.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
209    }
210
211    #[test]
212    fn test_error_response_bad_request() {
213        let err = AppError::BadRequest("Test error".to_string());
214        let response = err.error_response();
215        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
216    }
217
218    #[test]
219    fn test_error_response_tool_not_found() {
220        let err = AppError::ToolNotFound("tool".to_string());
221        let response = err.error_response();
222        assert_eq!(response.status(), StatusCode::NOT_FOUND);
223    }
224
225    #[test]
226    fn test_error_response_proxy_auth_includes_code() {
227        let err = AppError::ProxyAuthRequired;
228        let response = err.error_response();
229        assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);
230    }
231
232    #[test]
233    fn test_app_error_debug() {
234        let err = AppError::BadRequest("test".to_string());
235        let debug_str = format!("{:?}", err);
236        assert!(debug_str.contains("BadRequest"));
237    }
238
239    #[test]
240    fn test_app_error_clone() {
241        let err1 = AppError::BadRequest("test".to_string());
242        // AppError derives Debug but not Clone
243        // This test verifies the Debug trait works
244        let debug_output = format!("{:?}", err1);
245        assert!(!debug_output.is_empty());
246    }
247
248    #[test]
249    fn test_result_type_ok() {
250        let result: Result<i32> = Ok(42);
251        assert!(result.is_ok());
252        assert!(matches!(result, Ok(42)));
253    }
254
255    #[test]
256    fn test_result_type_err() {
257        let result: Result<i32> = Err(AppError::BadRequest("error".to_string()));
258        assert!(result.is_err());
259    }
260
261    #[test]
262    fn test_internal_error_from_anyhow() {
263        let anyhow_err = anyhow::anyhow!("Test error");
264        let app_error: AppError = anyhow_err.into();
265        assert!(matches!(app_error, AppError::InternalError(_)));
266    }
267
268    #[test]
269    fn test_storage_error_from_io() {
270        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
271        let app_error: AppError = io_err.into();
272        assert!(matches!(app_error, AppError::StorageError(_)));
273    }
274
275    #[test]
276    fn test_serialization_error_from_serde_json() {
277        let json_err = serde_json::from_str::<bool>("not a bool").unwrap_err();
278        let app_error: AppError = json_err.into();
279        assert!(matches!(app_error, AppError::SerializationError(_)));
280    }
281
282    /// The canonical envelope is a nested `{"error": {"message", "type"}}` —
283    /// `AppError::error_response` and [`error_value`] must agree on the same
284    /// shape (modulo the extra sibling fields `error_value` callers add
285    /// alongside `"error"`). #251 (finding 2).
286    #[actix_web::test]
287    async fn app_error_and_error_value_agree_on_envelope_shape() {
288        let resp = AppError::NotFound("Session".to_string()).error_response();
289        let bytes = actix_web::body::to_bytes(resp.into_body()).await.unwrap();
290        let app_err_body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
291        assert_eq!(app_err_body["error"]["type"], "api_error");
292        assert!(app_err_body["error"]["message"].is_string());
293
294        let helper_error = error_value("Session not found");
295        assert_eq!(helper_error["type"], "api_error");
296        assert_eq!(helper_error["message"], "Session not found");
297
298        // Same "type" tag on both shapes.
299        assert_eq!(
300            app_err_body["error"]["type"], helper_error["type"],
301            "AppError and error_value must use the same error \"type\" tag"
302        );
303    }
304}