Skip to main content

solti_api/
error.rs

1//! # API error types.
2
3use thiserror::Error;
4
5#[derive(Debug, Error)]
6#[non_exhaustive]
7pub enum ApiError {
8    /// Request was syntactically or semantically invalid (bad field, malformed body, missing required value). → `400` / `InvalidArgument`.
9    #[error("invalid request: {0}")]
10    InvalidRequest(String),
11
12    /// Credential missing, malformed, or rejected.
13    #[error("unauthenticated: {0}")]
14    Unauthenticated(String),
15
16    /// No task matched the requested name/id. → `404` / `NotFound`.
17    #[error("task not found: {0}")]
18    TaskNotFound(String),
19
20    /// Request body exceeded the configured limit. → `413` / `ResourceExhausted`.
21    #[error("payload too large: {0}")]
22    PayloadTooLarge(String),
23
24    /// Unexpected server-side failure with no more specific mapping.
25    #[error("internal error: {0}")]
26    Internal(String),
27
28    /// A failure from the [`solti_core`] layer, mapped variant-by-variant
29    #[error("core error: {0}")]
30    Core(#[from] solti_core::CoreError),
31}
32
33impl ApiError {
34    /// Short stable label for this variant, surfaced in HTTP error bodies and logs.
35    ///
36    /// `Core` is flattened to the same two buckets used by the wire mappings:
37    /// `InvalidSpec` presents as `InvalidRequest`, anything else as `Internal`.
38    pub fn as_label(&self) -> &'static str {
39        match self {
40            ApiError::Core(solti_core::CoreError::InvalidSpec(_)) => "InvalidRequest",
41            ApiError::Core(solti_core::CoreError::AlreadyExists(_)) => "AlreadyExists",
42            ApiError::Core(solti_core::CoreError::NotFound(_)) => "TaskNotFound",
43            ApiError::PayloadTooLarge(_) => "PayloadTooLarge",
44            ApiError::InvalidRequest(_) => "InvalidRequest",
45            ApiError::Unauthenticated(_) => "Unauthenticated",
46            ApiError::TaskNotFound(_) => "TaskNotFound",
47            ApiError::Internal(_) => "Internal",
48            ApiError::Core(_) => "Internal",
49        }
50    }
51}
52
53#[cfg(feature = "grpc")]
54impl From<ApiError> for tonic::Status {
55    fn from(err: ApiError) -> Self {
56        match err {
57            ApiError::PayloadTooLarge(msg) => tonic::Status::resource_exhausted(msg),
58            ApiError::InvalidRequest(msg) => tonic::Status::invalid_argument(msg),
59            ApiError::Unauthenticated(msg) => tonic::Status::unauthenticated(msg),
60            ApiError::TaskNotFound(msg) => tonic::Status::not_found(msg),
61            ApiError::Internal(msg) => tonic::Status::internal(msg),
62            ApiError::Core(e) => core_to_status(e),
63        }
64    }
65}
66
67#[cfg(feature = "grpc")]
68fn core_to_status(e: solti_core::CoreError) -> tonic::Status {
69    use solti_core::CoreError;
70    match e {
71        CoreError::InvalidSpec(inner) => tonic::Status::invalid_argument(inner.to_string()),
72        CoreError::AlreadyExists(msg) => tonic::Status::already_exists(msg),
73        CoreError::NotFound(msg) => tonic::Status::not_found(msg),
74        CoreError::Supervisor(_) | CoreError::Mapping(_) | CoreError::Runner(_) => {
75            tonic::Status::internal(e.to_string())
76        }
77        // `CoreError` is `#[non_exhaustive]`: any future variant is conservatively
78        // surfaced as `Internal` rather than silently dropped.
79        _ => tonic::Status::internal(e.to_string()),
80    }
81}
82
83#[cfg(feature = "http")]
84impl axum::response::IntoResponse for ApiError {
85    fn into_response(self) -> axum::response::Response {
86        use axum::http::StatusCode;
87
88        let label = self.as_label();
89        let (status, message) = match self {
90            ApiError::InvalidRequest(msg) => (StatusCode::BAD_REQUEST, msg),
91            ApiError::Unauthenticated(msg) => (StatusCode::UNAUTHORIZED, msg),
92            ApiError::TaskNotFound(msg) => (StatusCode::NOT_FOUND, msg),
93            ApiError::PayloadTooLarge(msg) => (StatusCode::PAYLOAD_TOO_LARGE, msg),
94            ApiError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
95            ApiError::Core(e) => core_to_http_status(e),
96        };
97
98        let body = serde_json::json!({ "error": label, "message": message });
99        (status, axum::Json(body)).into_response()
100    }
101}
102
103#[cfg(feature = "http")]
104fn core_to_http_status(e: solti_core::CoreError) -> (axum::http::StatusCode, String) {
105    use axum::http::StatusCode;
106    use solti_core::CoreError;
107    match e {
108        CoreError::InvalidSpec(inner) => (StatusCode::BAD_REQUEST, inner.to_string()),
109        CoreError::AlreadyExists(msg) => (StatusCode::CONFLICT, msg),
110        CoreError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
111        CoreError::Supervisor(_) | CoreError::Mapping(_) | CoreError::Runner(_) => {
112            (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
113        }
114        // `CoreError` is `#[non_exhaustive]`: any future variant is conservatively
115        // surfaced as `500 Internal Server Error` rather than silently dropped.
116        _ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn as_label_covers_all_direct_variants() {
126        assert_eq!(
127            ApiError::InvalidRequest("x".into()).as_label(),
128            "InvalidRequest"
129        );
130        assert_eq!(
131            ApiError::TaskNotFound("x".into()).as_label(),
132            "TaskNotFound"
133        );
134        assert_eq!(ApiError::Internal("x".into()).as_label(), "Internal");
135    }
136
137    #[test]
138    fn as_label_flattens_core_invalid_spec_to_invalid_request() {
139        let inner = solti_model::ModelError::Invalid("bad".into());
140        let e = ApiError::Core(solti_core::CoreError::InvalidSpec(inner));
141        assert_eq!(e.as_label(), "InvalidRequest");
142    }
143
144    #[test]
145    fn as_label_maps_core_already_exists_and_not_found() {
146        let dup = ApiError::Core(solti_core::CoreError::AlreadyExists("t".into()));
147        assert_eq!(dup.as_label(), "AlreadyExists");
148
149        let missing = ApiError::Core(solti_core::CoreError::NotFound("t".into()));
150        assert_eq!(missing.as_label(), "TaskNotFound");
151    }
152
153    #[cfg(feature = "http")]
154    #[test]
155    fn core_already_exists_is_conflict_and_not_found_is_404() {
156        use axum::http::StatusCode;
157        let (status, _) = core_to_http_status(solti_core::CoreError::AlreadyExists("t".into()));
158        assert_eq!(status, StatusCode::CONFLICT);
159        let (status, _) = core_to_http_status(solti_core::CoreError::NotFound("t".into()));
160        assert_eq!(status, StatusCode::NOT_FOUND);
161    }
162
163    // `CoreError` is `#[non_exhaustive]`, so the compiler no longer forces these
164    // mappers to cover every variant. These tests pin the known mappings instead:
165    // a maintainer adding a variant should extend the mappers (and this test).
166    #[cfg(feature = "http")]
167    #[test]
168    fn core_to_http_status_maps_every_known_variant() {
169        use axum::http::StatusCode;
170        use solti_core::CoreError;
171
172        let cases = [
173            (
174                CoreError::InvalidSpec(solti_model::ModelError::Invalid("x".into())),
175                StatusCode::BAD_REQUEST,
176            ),
177            (CoreError::AlreadyExists("x".into()), StatusCode::CONFLICT),
178            (CoreError::NotFound("x".into()), StatusCode::NOT_FOUND),
179            (
180                CoreError::Supervisor("x".into()),
181                StatusCode::INTERNAL_SERVER_ERROR,
182            ),
183            (
184                CoreError::Mapping("x".into()),
185                StatusCode::INTERNAL_SERVER_ERROR,
186            ),
187        ];
188        for (err, expected) in cases {
189            assert_eq!(core_to_http_status(err).0, expected);
190        }
191    }
192
193    #[cfg(feature = "grpc")]
194    #[test]
195    fn core_to_status_maps_every_known_variant() {
196        use solti_core::CoreError;
197        use tonic::Code;
198
199        let cases = [
200            (
201                CoreError::InvalidSpec(solti_model::ModelError::Invalid("x".into())),
202                Code::InvalidArgument,
203            ),
204            (CoreError::AlreadyExists("x".into()), Code::AlreadyExists),
205            (CoreError::NotFound("x".into()), Code::NotFound),
206            (CoreError::Supervisor("x".into()), Code::Internal),
207            (CoreError::Mapping("x".into()), Code::Internal),
208        ];
209        for (err, expected) in cases {
210            assert_eq!(core_to_status(err).code(), expected);
211        }
212    }
213}