Skip to main content

kranz_server/
error.rs

1//! Uniform JSON error responses: every failure body carries `{"error": "..."}`
2//! with an appropriate status and an optional stable `code` for recovery.
3//! Handlers never panic on corrupt input —
4//! corrupt logs / files surface as error responses.
5
6use axum::http::StatusCode;
7use axum::response::{IntoResponse, Response};
8use axum::Json;
9use kranz_engine::error::EngineError;
10use serde::Serialize;
11use serde_json::json;
12use std::io::ErrorKind;
13
14/// Stable recovery hints for clients. Human-readable messages remain separate.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
16#[serde(rename_all = "snake_case")]
17pub enum ApiErrorCode {
18    MissionNotHosted,
19    TurnInFlight,
20    RepositoryBusy,
21    StalePlan,
22}
23
24#[derive(Debug)]
25pub struct ApiError {
26    pub status: StatusCode,
27    pub message: String,
28    pub code: Option<ApiErrorCode>,
29}
30
31impl ApiError {
32    pub fn not_found(message: impl Into<String>) -> Self {
33        Self {
34            status: StatusCode::NOT_FOUND,
35            message: message.into(),
36            code: None,
37        }
38    }
39
40    pub fn bad_request(message: impl Into<String>) -> Self {
41        Self {
42            status: StatusCode::BAD_REQUEST,
43            message: message.into(),
44            code: None,
45        }
46    }
47
48    pub fn conflict(message: impl Into<String>) -> Self {
49        Self {
50            status: StatusCode::CONFLICT,
51            message: message.into(),
52            code: None,
53        }
54    }
55
56    /// Authentication failed or was not presented (the webhook HMAC).
57    pub fn unauthorized(message: impl Into<String>) -> Self {
58        Self {
59            status: StatusCode::UNAUTHORIZED,
60            message: message.into(),
61            code: None,
62        }
63    }
64
65    /// Authenticated or not, this route refuses the request (webhook for the
66    /// wrong repository, or hooks not configured — refused closed).
67    pub fn forbidden(message: impl Into<String>) -> Self {
68        Self {
69            status: StatusCode::FORBIDDEN,
70            message: message.into(),
71            code: None,
72        }
73    }
74
75    pub fn internal(message: impl Into<String>) -> Self {
76        Self {
77            status: StatusCode::INTERNAL_SERVER_ERROR,
78            message: message.into(),
79            code: None,
80        }
81    }
82
83    /// A gated merge whose gate suite failed — the request was well-formed
84    /// but the mission's state is not mergeable yet.
85    pub fn unprocessable(message: impl Into<String>) -> Self {
86        Self {
87            status: StatusCode::UNPROCESSABLE_ENTITY,
88            message: message.into(),
89            code: None,
90        }
91    }
92
93    pub fn with_code(mut self, code: ApiErrorCode) -> Self {
94        self.code = Some(code);
95        self
96    }
97}
98
99impl IntoResponse for ApiError {
100    fn into_response(self) -> Response {
101        let mut body = json!({ "error": self.message });
102        if let Some(code) = self.code {
103            body["code"] = json!(code);
104        }
105        (self.status, Json(body)).into_response()
106    }
107}
108
109impl From<EngineError> for ApiError {
110    fn from(error: EngineError) -> Self {
111        match &error {
112            EngineError::Io(io) if io.kind() == ErrorKind::NotFound => {
113                ApiError::not_found(error.to_string())
114            }
115            // Another engine (CLI or a hosted run) holds the single-writer
116            // lock, or the mission is in the wrong lifecycle state for the
117            // requested transition: conflicts, not server failures.
118            EngineError::LockHeld(_) | EngineError::InvalidState(_) => {
119                ApiError::conflict(error.to_string())
120            }
121            // Config errors surface from user-supplied patches (and from
122            // config files the message names) — the request is at fault.
123            EngineError::Config(_) => ApiError::bad_request(error.to_string()),
124            _ => ApiError::internal(error.to_string()),
125        }
126    }
127}