Skip to main content

solti_api/
error.rs

1//! # API error types.
2
3use thiserror::Error;
4
5#[derive(Debug, Error)]
6pub enum ApiError {
7    #[error("invalid request: {0}")]
8    InvalidRequest(String),
9
10    #[error("task not found: {0}")]
11    TaskNotFound(String),
12
13    #[error("payload too large: {0}")]
14    PayloadTooLarge(String),
15
16    #[error("internal error: {0}")]
17    Internal(String),
18
19    #[error("core error: {0}")]
20    Core(#[from] solti_core::CoreError),
21}
22
23impl ApiError {
24    /// Short stable label for this variant, surfaced in HTTP error bodies and logs.
25    ///
26    /// `Core` is flattened to the same two buckets used by the wire mappings:
27    /// `InvalidSpec` presents as `InvalidRequest`, anything else as `Internal`.
28    pub fn as_label(&self) -> &'static str {
29        match self {
30            ApiError::Core(solti_core::CoreError::InvalidSpec(_)) => "InvalidRequest",
31            ApiError::PayloadTooLarge(_) => "PayloadTooLarge",
32            ApiError::InvalidRequest(_) => "InvalidRequest",
33            ApiError::TaskNotFound(_) => "TaskNotFound",
34            ApiError::Internal(_) => "Internal",
35            ApiError::Core(_) => "Internal",
36        }
37    }
38}
39
40#[cfg(feature = "grpc")]
41impl From<ApiError> for tonic::Status {
42    fn from(err: ApiError) -> Self {
43        match err {
44            ApiError::PayloadTooLarge(msg) => tonic::Status::resource_exhausted(msg),
45            ApiError::InvalidRequest(msg) => tonic::Status::invalid_argument(msg),
46            ApiError::TaskNotFound(msg) => tonic::Status::not_found(msg),
47            ApiError::Internal(msg) => tonic::Status::internal(msg),
48            ApiError::Core(e) => core_to_status(e),
49        }
50    }
51}
52
53#[cfg(feature = "grpc")]
54fn core_to_status(e: solti_core::CoreError) -> tonic::Status {
55    use solti_core::CoreError;
56    match e {
57        CoreError::InvalidSpec(inner) => tonic::Status::invalid_argument(inner.to_string()),
58        CoreError::Supervisor(_) | CoreError::Mapping(_) | CoreError::Runner(_) => {
59            tonic::Status::internal(e.to_string())
60        }
61    }
62}
63
64#[cfg(feature = "http")]
65impl axum::response::IntoResponse for ApiError {
66    fn into_response(self) -> axum::response::Response {
67        use axum::http::StatusCode;
68
69        let label = self.as_label();
70        let (status, message) = match self {
71            ApiError::InvalidRequest(msg) => (StatusCode::BAD_REQUEST, msg),
72            ApiError::TaskNotFound(msg) => (StatusCode::NOT_FOUND, msg),
73            ApiError::PayloadTooLarge(msg) => (StatusCode::PAYLOAD_TOO_LARGE, msg),
74            ApiError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
75            ApiError::Core(e) => core_to_http_status(e),
76        };
77
78        let body = serde_json::json!({ "error": label, "message": message });
79        (status, axum::Json(body)).into_response()
80    }
81}
82
83#[cfg(feature = "http")]
84fn core_to_http_status(e: solti_core::CoreError) -> (axum::http::StatusCode, String) {
85    use axum::http::StatusCode;
86    use solti_core::CoreError;
87    match e {
88        CoreError::InvalidSpec(inner) => (StatusCode::BAD_REQUEST, inner.to_string()),
89        CoreError::Supervisor(_) | CoreError::Mapping(_) | CoreError::Runner(_) => {
90            (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
91        }
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn as_label_covers_all_direct_variants() {
101        assert_eq!(
102            ApiError::InvalidRequest("x".into()).as_label(),
103            "InvalidRequest"
104        );
105        assert_eq!(
106            ApiError::TaskNotFound("x".into()).as_label(),
107            "TaskNotFound"
108        );
109        assert_eq!(ApiError::Internal("x".into()).as_label(), "Internal");
110    }
111
112    #[test]
113    fn as_label_flattens_core_invalid_spec_to_invalid_request() {
114        let inner = solti_model::ModelError::Invalid("bad".into());
115        let e = ApiError::Core(solti_core::CoreError::InvalidSpec(inner));
116        assert_eq!(e.as_label(), "InvalidRequest");
117    }
118}