Skip to main content

solti_api/
error.rs

1//! # API Errors
2//!
3//! [`ApiError`] is the shared error contract for both transports.
4//! HTTP converts it into a Kubernetes-style `Status` resource.
5//! gRPC converts it into `tonic::Status`.
6//!
7//! Write conflicts carry structured [`ApiConflict`] details.
8//! Internal diagnostics are logged and hidden from wire clients.
9
10use std::fmt;
11
12use thiserror::Error;
13
14/// One machine-readable cause of an API conflict.
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct ApiErrorCause {
17    reason: String,
18    field: Option<String>,
19    message: String,
20}
21
22impl ApiErrorCause {
23    /// Creates a cause with a reason and readable message.
24    pub fn new(reason: impl Into<String>, message: impl Into<String>) -> Self {
25        Self {
26            reason: reason.into(),
27            field: None,
28            message: message.into(),
29        }
30    }
31
32    /// Attaches the related request field.
33    pub fn with_field(mut self, field: impl Into<String>) -> Self {
34        self.field = Some(field.into());
35        self
36    }
37
38    /// Returns the machine-readable reason.
39    pub fn reason(&self) -> &str {
40        &self.reason
41    }
42
43    /// Returns the related request field.
44    pub fn field(&self) -> Option<&str> {
45        self.field.as_deref()
46    }
47
48    /// Returns the readable diagnostic.
49    pub fn message(&self) -> &str {
50        &self.message
51    }
52}
53
54/// Structured optimistic concurrency conflict.
55///
56/// Each cause describes one failed write precondition.
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct ApiConflict {
59    name: String,
60    causes: Vec<ApiErrorCause>,
61}
62
63impl ApiConflict {
64    /// Creates conflict details for one task.
65    pub fn new(name: impl Into<String>, causes: Vec<ApiErrorCause>) -> Self {
66        Self {
67            name: name.into(),
68            causes,
69        }
70    }
71
72    /// Returns the conflicting task name.
73    pub fn name(&self) -> &str {
74        &self.name
75    }
76
77    /// Returns the failed preconditions.
78    pub fn causes(&self) -> &[ApiErrorCause] {
79        &self.causes
80    }
81}
82
83impl fmt::Display for ApiConflict {
84    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
85        write!(
86            formatter,
87            "write precondition failed for Task `{}`",
88            self.name
89        )?;
90        for (index, cause) in self.causes.iter().enumerate() {
91            if index == 0 {
92                formatter.write_str(": ")?;
93            } else {
94                formatter.write_str("; ")?;
95            }
96            formatter.write_str(cause.message())?;
97        }
98        Ok(())
99    }
100}
101
102impl std::error::Error for ApiConflict {}
103
104/// Error returned by the handler or a transport boundary.
105///
106/// | Variant                    | HTTP  | gRPC                |
107/// |----------------------------|-------|---------------------|
108/// | `InvalidRequest`           | `400` | `InvalidArgument`   |
109/// | `Unauthenticated`          | `401` | `Unauthenticated`   |
110/// | `AlreadyExists`            | `409` | `AlreadyExists`     |
111/// | `Conflict`                 | `409` | `Aborted`           |
112/// | `TaskNotFound`, `NotFound` | `404` | `NotFound`          |
113/// | `MethodNotAllowed`         | `405` | `Unimplemented`     |
114/// | `UnsupportedMediaType`     | `415` | `InvalidArgument`   |
115/// | `PayloadTooLarge`          | `413` | `ResourceExhausted` |
116/// | `ResourceVersionExpired`   | `410` | `OutOfRange`        |
117/// | `Unavailable`              | `503` | `Unavailable`       |
118/// | `Internal`                 | `500` | `Internal`          |
119///
120/// This enum is non-exhaustive.
121/// Match it with a wildcard arm.
122#[derive(Debug, Error)]
123#[non_exhaustive]
124pub enum ApiError {
125    /// The request is syntactically or semantically invalid.
126    #[error("invalid request: {0}")]
127    InvalidRequest(String),
128
129    /// The bearer credential is missing, malformed, or rejected.
130    #[error("unauthenticated: {0}")]
131    Unauthenticated(String),
132
133    /// A retained task already owns the requested name.
134    #[error("task already exists: {0}")]
135    AlreadyExists(String),
136
137    /// A write precondition does not match the current task.
138    #[error(transparent)]
139    Conflict(ApiConflict),
140
141    /// No public task has the requested name.
142    #[error("task not found: {0}")]
143    TaskNotFound(String),
144
145    /// No public resource or route matches the request.
146    #[error("not found: {0}")]
147    NotFound(String),
148
149    /// The resource does not support the requested method.
150    #[error("method not allowed: {0}")]
151    MethodNotAllowed(String),
152
153    /// The request media type is missing or unsupported.
154    #[error("unsupported media type: {0}")]
155    UnsupportedMediaType(String),
156
157    /// The request body or message exceeds the configured limit.
158    #[error("payload too large: {0}")]
159    PayloadTooLarge(String),
160
161    /// The requested list snapshot or watch position is no longer retained.
162    #[error("resource version expired: {0}")]
163    ResourceVersionExpired(String),
164
165    /// The service cannot currently accept work.
166    #[error("service unavailable: {0}")]
167    Unavailable(String),
168
169    /// An unexpected server-side failure occurred.
170    #[error("internal error: {0}")]
171    Internal(String),
172}
173
174impl ApiError {
175    /// Returns the stable variant label.
176    pub fn as_label(&self) -> &'static str {
177        match self {
178            ApiError::PayloadTooLarge(_) => "PayloadTooLarge",
179            ApiError::InvalidRequest(_) => "InvalidRequest",
180            ApiError::Unauthenticated(_) => "Unauthenticated",
181            ApiError::AlreadyExists(_) => "AlreadyExists",
182            ApiError::Conflict(_) => "Conflict",
183            ApiError::TaskNotFound(_) => "TaskNotFound",
184            ApiError::NotFound(_) => "NotFound",
185            ApiError::MethodNotAllowed(_) => "MethodNotAllowed",
186            ApiError::UnsupportedMediaType(_) => "UnsupportedMediaType",
187            ApiError::ResourceVersionExpired(_) => "ResourceVersionExpired",
188            ApiError::Unavailable(_) => "Unavailable",
189            ApiError::Internal(_) => "Internal",
190        }
191    }
192
193    #[cfg(feature = "http")]
194    fn http_reason(&self) -> &'static str {
195        match self {
196            ApiError::InvalidRequest(_) => "BadRequest",
197            ApiError::Unauthenticated(_) => "Unauthorized",
198            ApiError::AlreadyExists(_) => "AlreadyExists",
199            ApiError::Conflict(_) => "Conflict",
200            ApiError::TaskNotFound(_) => "NotFound",
201            ApiError::NotFound(_) => "NotFound",
202            ApiError::MethodNotAllowed(_) => "MethodNotAllowed",
203            ApiError::UnsupportedMediaType(_) => "UnsupportedMediaType",
204            ApiError::PayloadTooLarge(_) => "RequestEntityTooLarge",
205            ApiError::ResourceVersionExpired(_) => "Expired",
206            ApiError::Unavailable(_) => "ServiceUnavailable",
207            ApiError::Internal(_) => "InternalError",
208        }
209    }
210
211    #[cfg(feature = "http")]
212    pub(crate) fn into_http_status(self) -> (axum::http::StatusCode, HttpStatusResource) {
213        use axum::http::StatusCode;
214
215        let reason = self.http_reason();
216        let (status, message, details) = match self {
217            ApiError::InvalidRequest(msg) => (StatusCode::BAD_REQUEST, msg, None),
218            ApiError::Unauthenticated(msg) => (StatusCode::UNAUTHORIZED, msg, None),
219            ApiError::AlreadyExists(msg) => (StatusCode::CONFLICT, msg, None),
220            ApiError::Conflict(conflict) => {
221                let details = HttpStatusDetails {
222                    name: conflict.name().to_owned(),
223                    group: "solti.io",
224                    kind: "Task",
225                    causes: conflict
226                        .causes()
227                        .iter()
228                        .map(|cause| HttpStatusCause {
229                            reason: cause.reason().to_owned(),
230                            field: cause.field().map(http_field_path),
231                            message: cause.message().to_owned(),
232                        })
233                        .collect(),
234                };
235                (StatusCode::CONFLICT, conflict.to_string(), Some(details))
236            }
237            ApiError::TaskNotFound(msg) => (StatusCode::NOT_FOUND, msg, None),
238            ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg, None),
239            ApiError::MethodNotAllowed(msg) => (StatusCode::METHOD_NOT_ALLOWED, msg, None),
240            ApiError::UnsupportedMediaType(msg) => (StatusCode::UNSUPPORTED_MEDIA_TYPE, msg, None),
241            ApiError::PayloadTooLarge(msg) => (StatusCode::PAYLOAD_TOO_LARGE, msg, None),
242            ApiError::ResourceVersionExpired(msg) => (StatusCode::GONE, msg, None),
243            ApiError::Unavailable(msg) => (StatusCode::SERVICE_UNAVAILABLE, msg, None),
244            ApiError::Internal(msg) => {
245                tracing::error!(error = %msg, "API request failed internally");
246                (
247                    StatusCode::INTERNAL_SERVER_ERROR,
248                    "internal server error".to_owned(),
249                    None,
250                )
251            }
252        };
253
254        let body = HttpStatusResource {
255            api_version: "v1",
256            kind: "Status",
257            metadata: HttpStatusMeta {},
258            status: "Failure",
259            message,
260            reason,
261            details,
262            code: status.as_u16(),
263        };
264        (status, body)
265    }
266}
267
268#[cfg(feature = "http")]
269#[derive(schemars::JsonSchema, serde::Serialize)]
270#[schemars(deny_unknown_fields)]
271#[serde(rename_all = "camelCase")]
272pub(crate) struct HttpStatusResource {
273    #[schemars(schema_with = "http_status_api_version")]
274    api_version: &'static str,
275    #[schemars(schema_with = "http_status_kind")]
276    kind: &'static str,
277    metadata: HttpStatusMeta,
278    #[schemars(schema_with = "http_status_value")]
279    status: &'static str,
280    message: String,
281    #[schemars(schema_with = "http_status_reason")]
282    reason: &'static str,
283    #[serde(skip_serializing_if = "Option::is_none")]
284    details: Option<HttpStatusDetails>,
285    #[schemars(range(min = 400, max = 599))]
286    code: u16,
287}
288
289#[cfg(feature = "http")]
290#[derive(schemars::JsonSchema, serde::Serialize)]
291#[schemars(deny_unknown_fields)]
292struct HttpStatusMeta {}
293
294#[cfg(feature = "http")]
295#[derive(schemars::JsonSchema, serde::Serialize)]
296#[schemars(deny_unknown_fields)]
297struct HttpStatusDetails {
298    name: String,
299    #[schemars(schema_with = "http_status_group")]
300    group: &'static str,
301    #[schemars(schema_with = "http_status_task_kind")]
302    kind: &'static str,
303    causes: Vec<HttpStatusCause>,
304}
305
306#[cfg(feature = "http")]
307#[derive(schemars::JsonSchema, serde::Serialize)]
308#[schemars(deny_unknown_fields)]
309struct HttpStatusCause {
310    reason: String,
311    #[serde(skip_serializing_if = "Option::is_none")]
312    field: Option<String>,
313    message: String,
314}
315
316#[cfg(feature = "http")]
317fn http_status_api_version(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
318    schemars::json_schema!({
319        "type": "string",
320        "const": "v1"
321    })
322}
323
324#[cfg(feature = "http")]
325fn http_status_kind(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
326    schemars::json_schema!({
327        "type": "string",
328        "const": "Status"
329    })
330}
331
332#[cfg(feature = "http")]
333fn http_status_value(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
334    schemars::json_schema!({
335        "type": "string",
336        "const": "Failure"
337    })
338}
339
340#[cfg(feature = "http")]
341fn http_status_reason(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
342    schemars::json_schema!({
343        "type": "string",
344        "enum": [
345            "AlreadyExists",
346            "BadRequest",
347            "Conflict",
348            "Expired",
349            "InternalError",
350            "MethodNotAllowed",
351            "NotFound",
352            "RequestEntityTooLarge",
353            "ServiceUnavailable",
354            "Unauthorized",
355            "UnsupportedMediaType"
356        ]
357    })
358}
359
360#[cfg(feature = "http")]
361fn http_status_group(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
362    schemars::json_schema!({
363        "type": "string",
364        "const": "solti.io"
365    })
366}
367
368#[cfg(feature = "http")]
369fn http_status_task_kind(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
370    schemars::json_schema!({
371        "type": "string",
372        "const": "Task"
373    })
374}
375
376#[cfg(feature = "grpc")]
377impl From<ApiError> for tonic::Status {
378    fn from(err: ApiError) -> Self {
379        match err {
380            ApiError::PayloadTooLarge(msg) => tonic::Status::resource_exhausted(msg),
381            ApiError::InvalidRequest(msg) => tonic::Status::invalid_argument(msg),
382            ApiError::Unauthenticated(msg) => tonic::Status::unauthenticated(msg),
383            ApiError::AlreadyExists(msg) => tonic::Status::already_exists(msg),
384            ApiError::Conflict(conflict) => {
385                use prost::Message as _;
386
387                let details = crate::proto_api::WriteConflictDetails {
388                    name: conflict.name().to_owned(),
389                    causes: conflict
390                        .causes()
391                        .iter()
392                        .map(|cause| crate::proto_api::StatusCause {
393                            reason: cause.reason().to_owned(),
394                            field: cause.field().map(str::to_owned),
395                            message: cause.message().to_owned(),
396                        })
397                        .collect(),
398                };
399                tonic::Status::with_details(
400                    tonic::Code::Aborted,
401                    conflict.to_string(),
402                    details.encode_to_vec().into(),
403                )
404            }
405            ApiError::TaskNotFound(msg) => tonic::Status::not_found(msg),
406            ApiError::NotFound(msg) => tonic::Status::not_found(msg),
407            ApiError::MethodNotAllowed(msg) => tonic::Status::unimplemented(msg),
408            ApiError::UnsupportedMediaType(msg) => tonic::Status::invalid_argument(msg),
409            ApiError::ResourceVersionExpired(msg) => tonic::Status::out_of_range(msg),
410            ApiError::Unavailable(msg) => tonic::Status::unavailable(msg),
411            ApiError::Internal(msg) => {
412                tracing::error!(error = %msg, "API request failed internally");
413                tonic::Status::internal("internal server error")
414            }
415        }
416    }
417}
418
419#[cfg(feature = "http")]
420impl axum::response::IntoResponse for ApiError {
421    fn into_response(self) -> axum::response::Response {
422        let (status, body) = self.into_http_status();
423        (status, axum::Json(body)).into_response()
424    }
425}
426
427#[cfg(feature = "http")]
428fn http_field_path(field: &str) -> String {
429    match field {
430        "preconditions.uid" => "uid".to_owned(),
431        "preconditions.resourceVersion" => "resourceVersion".to_owned(),
432        other => other.to_owned(),
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    fn conflict() -> ApiConflict {
441        ApiConflict::new(
442            "task-1",
443            vec![
444                ApiErrorCause::new("ResourceVersionMismatch", "expected `1`, current `2`")
445                    .with_field("preconditions.resourceVersion"),
446            ],
447        )
448    }
449
450    #[test]
451    fn as_label_covers_all_direct_variants() {
452        let cases = [
453            (ApiError::InvalidRequest("x".into()), "InvalidRequest"),
454            (ApiError::Unauthenticated("x".into()), "Unauthenticated"),
455            (ApiError::AlreadyExists("x".into()), "AlreadyExists"),
456            (ApiError::Conflict(conflict()), "Conflict"),
457            (ApiError::TaskNotFound("x".into()), "TaskNotFound"),
458            (ApiError::NotFound("x".into()), "NotFound"),
459            (ApiError::MethodNotAllowed("x".into()), "MethodNotAllowed"),
460            (
461                ApiError::UnsupportedMediaType("x".into()),
462                "UnsupportedMediaType",
463            ),
464            (ApiError::PayloadTooLarge("x".into()), "PayloadTooLarge"),
465            (
466                ApiError::ResourceVersionExpired("x".into()),
467                "ResourceVersionExpired",
468            ),
469            (ApiError::Unavailable("x".into()), "Unavailable"),
470            (ApiError::Internal("x".into()), "Internal"),
471        ];
472
473        for (error, expected) in cases {
474            assert_eq!(error.as_label(), expected);
475        }
476    }
477
478    #[cfg(feature = "http")]
479    #[test]
480    fn direct_errors_map_to_http_status_codes() {
481        use axum::http::StatusCode;
482        use axum::response::IntoResponse;
483
484        for (error, expected) in [
485            (ApiError::AlreadyExists("x".into()), StatusCode::CONFLICT),
486            (
487                ApiError::ResourceVersionExpired("old revision".into()),
488                StatusCode::GONE,
489            ),
490            (ApiError::Conflict(conflict()), StatusCode::CONFLICT),
491            (
492                ApiError::Unavailable("x".into()),
493                StatusCode::SERVICE_UNAVAILABLE,
494            ),
495        ] {
496            assert_eq!(error.into_response().status(), expected);
497        }
498    }
499
500    #[cfg(feature = "grpc")]
501    #[test]
502    fn direct_errors_map_to_grpc_status_codes() {
503        use tonic::Code;
504
505        for (error, expected) in [
506            (ApiError::AlreadyExists("x".into()), Code::AlreadyExists),
507            (
508                ApiError::ResourceVersionExpired("old revision".into()),
509                Code::OutOfRange,
510            ),
511            (ApiError::Unavailable("x".into()), Code::Unavailable),
512        ] {
513            assert_eq!(tonic::Status::from(error).code(), expected);
514        }
515    }
516
517    #[cfg(feature = "grpc")]
518    #[test]
519    fn conflict_maps_to_grpc_aborted() {
520        use prost::Message as _;
521        use tonic::Code;
522
523        let status = tonic::Status::from(ApiError::Conflict(conflict()));
524        assert_eq!(status.code(), Code::Aborted);
525        let details = crate::proto_api::WriteConflictDetails::decode(status.details()).unwrap();
526        assert_eq!(details.name, "task-1");
527        assert_eq!(details.causes.len(), 1);
528        assert_eq!(details.causes[0].reason, "ResourceVersionMismatch");
529        assert_eq!(
530            details.causes[0].field.as_deref(),
531            Some("preconditions.resourceVersion")
532        );
533    }
534
535    #[cfg(feature = "http")]
536    #[tokio::test]
537    async fn conflict_http_status_contains_structured_causes() {
538        use axum::response::IntoResponse;
539        use http_body_util::BodyExt;
540
541        let response = ApiError::Conflict(conflict()).into_response();
542        let body = response.into_body().collect().await.unwrap().to_bytes();
543        let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
544
545        assert_eq!(value["reason"], "Conflict");
546        assert_eq!(value["details"]["name"], "task-1");
547        assert_eq!(value["details"]["group"], "solti.io");
548        assert_eq!(value["details"]["kind"], "Task");
549        assert_eq!(
550            value["details"]["causes"][0]["reason"],
551            "ResourceVersionMismatch"
552        );
553        assert_eq!(value["details"]["causes"][0]["field"], "resourceVersion");
554    }
555
556    #[cfg(feature = "grpc")]
557    #[test]
558    fn grpc_internal_error_hides_diagnostic_message() {
559        let status = tonic::Status::from(ApiError::Internal("secret diagnostic".into()));
560        assert_eq!(status.code(), tonic::Code::Internal);
561        assert_eq!(status.message(), "internal server error");
562    }
563
564    #[cfg(feature = "http")]
565    #[tokio::test]
566    async fn http_internal_error_hides_diagnostic_message() {
567        use axum::response::IntoResponse;
568        use http_body_util::BodyExt;
569
570        let response = ApiError::Internal("secret diagnostic".into()).into_response();
571        let body = response.into_body().collect().await.unwrap().to_bytes();
572        let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
573
574        assert_eq!(value["message"], "internal server error");
575        assert!(!String::from_utf8_lossy(&body).contains("secret diagnostic"));
576    }
577}