use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::Json; use serde_json::json; /// API-surface errors with their HTTP mapping. Internal causes are logged /// server-side, never echoed to clients. #[derive(Debug, thiserror::Error)] pub enum ApiError { #[error("bad request")] BadRequest, #[error("unauthorized")] Unauthorized, #[error("forbidden")] Forbidden, #[error("not found")] NotFound, #[error("conflict")] Conflict, #[error("{0}")] Quota(String), /// A 400 whose REASON the caller needs. /// /// Same argument as `Unavailable` below, one status code down. The /// proposal decide handlers each computed a precise refusal — "the mission /// is running, not a draft", "no node can boot that backend any more" — /// logged it to stderr, and returned a bare `BadRequest`. The person who /// needed the sentence was the one clicking Approve, and they got /// "bad request". `mission_plan::Refusal` exists and is written as /// human-readable copy; this is how it reaches them. #[error("{0}")] Refused(String), /// A dependency is temporarily refusing work and will accept it later — /// today, the Claude Code subscription's rate limit. Distinct from /// `Internal` because the operator's next action is different: wait and /// press the button again, rather than read a server log. A 500 with /// "internal error" sent them looking for a bug that was not there. #[error("{0}")] Unavailable(String), #[error("internal error")] Internal, } impl From for ApiError { fn from(err: cm_db::DbError) -> Self { match err { cm_db::DbError::NotFound => ApiError::NotFound, _ => ApiError::Internal, } } } impl From for ApiError { fn from(err: sqlx::Error) -> Self { ApiError::from(cm_db::DbError::from(err)) } } impl From for ApiError { fn from(err: cm_auth::AuthError) -> Self { match err { cm_auth::AuthError::InvalidCredentials | cm_auth::AuthError::Unauthenticated => { ApiError::Unauthorized } _ => ApiError::Internal, } } } impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = match self { ApiError::BadRequest | ApiError::Refused(_) => StatusCode::BAD_REQUEST, ApiError::Unauthorized => StatusCode::UNAUTHORIZED, ApiError::Forbidden => StatusCode::FORBIDDEN, ApiError::NotFound => StatusCode::NOT_FOUND, ApiError::Conflict => StatusCode::CONFLICT, ApiError::Quota(_) => StatusCode::PAYMENT_REQUIRED, ApiError::Unavailable(_) => StatusCode::SERVICE_UNAVAILABLE, ApiError::Internal => StatusCode::INTERNAL_SERVER_ERROR, }; (status, Json(json!({ "error": self.to_string() }))).into_response() } } #[cfg(test)] mod tests { use super::*; use axum::body::to_bytes; /// A refusal must carry its reason into the response body. /// /// The proposal decide handlers each computed a precise sentence and then /// returned a bare `BadRequest`, so the person clicking Approve saw /// "bad request" while the reason went to a server log they cannot read. #[tokio::test] async fn a_refusal_reaches_the_caller_and_a_bare_bad_request_does_not_pretend_to() { let refused = ApiError::Refused("this mission is running, not a draft".into()); let response = refused.into_response(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap(); let text = String::from_utf8_lossy(&body); assert!( text.contains("running, not a draft"), "the reason must be in the body, not only in the server log: {text}" ); // The bare variant stays as it was — same status, no invented detail. let bare = ApiError::BadRequest.into_response(); assert_eq!(bare.status(), StatusCode::BAD_REQUEST); } }