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("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
84/// The value of the `"error"` key in bamboo's canonical error envelope
85/// (`{"error": {"message", "type", "code"}}`, matching [`AppError`]'s
86/// [`ResponseError::error_response`] body) — for the minority of handlers
87/// that are not (yet) modeled as an `AppError` variant but still need to
88/// return extra sibling fields alongside the error (e.g. `session_id`,
89/// `message_id`) that a bare `AppError` has no place for.
90///
91/// Prefer returning `Result<_, AppError>` directly when there are no extra
92/// sibling fields — this helper exists only to converge the remaining
93/// hand-written `HttpResponse::X().json(json!({"error": "<flat string>", ...}))`
94/// call sites (#251 finding 2) onto the SAME envelope shape as `AppError`,
95/// without forcing every one of them through a full `AppError` variant.
96///
97/// # Example
98/// ```ignore
99/// HttpResponse::NotFound().json(serde_json::json!({
100///     "error": error_value("Session not found"),
101///     "session_id": session_id,
102/// }))
103/// ```
104pub fn error_value(message: impl Into<String>) -> serde_json::Value {
105    serde_json::json!({ "message": message.into(), "type": "api_error" })
106}
107
108/// Build bamboo's canonical JSON error response for code paths that cannot
109/// return an [`AppError`] directly (most notably middleware and handlers that
110/// must preserve extra response headers).
111pub fn json_error(status: StatusCode, message: impl Into<String>) -> HttpResponse {
112    json_error_with_code(status, message.into(), None)
113}
114
115/// Preserve an existing `actix_web::Result` signature while ensuring the
116/// framework error is rendered with bamboo's canonical JSON envelope instead
117/// of Actix's default `text/plain` convenience-error body.
118pub 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        // AppError derives Debug but not Clone
266        // This test verifies the Debug trait works
267        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    /// The canonical envelope is a nested `{"error": {"message", "type"}}` —
306    /// `AppError::error_response` and [`error_value`] must agree on the same
307    /// shape (modulo the extra sibling fields `error_value` callers add
308    /// alongside `"error"`). #251 (finding 2).
309    #[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        // Same "type" tag on both shapes.
322        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    /// Source-level tripwire for Bamboo's native HTTP surface. Vendor-compat
351    /// handlers and the native WS frame protocol intentionally own different
352    /// wire contracts, but native REST handlers and middleware must not
353    /// reintroduce either a flat `error` value or Actix's text/plain
354    /// convenience errors.
355    #[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: &regex::Regex,
406        actix_convenience_error: &regex::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}