Skip to main content

klieo_workflow_api/
error.rs

1//! Typed API failures and their HTTP mapping. Client-facing responses carry
2//! a stable snake_case code (plus an actionable detail for authoring errors);
3//! internal detail is logged server-side, never returned.
4
5use axum::{
6    http::{header::RETRY_AFTER, HeaderValue, StatusCode},
7    response::{IntoResponse, Response},
8    Json,
9};
10use klieo_workflow::CompileError;
11use serde::Serialize;
12
13/// Coarse hint (seconds) returned in `Retry-After` when the concurrency cap
14/// is saturated. Deliberately small — capacity frees as in-flight runs finish.
15const RETRY_AFTER_SECONDS: &str = "1";
16
17/// Stable error codes returned in the JSON body of every non-2xx response.
18#[non_exhaustive]
19#[derive(Debug, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum ErrorCode {
22    /// The request body was malformed or semantically invalid.
23    BadRequest,
24    /// The submitted `WorkflowDef` failed to compile against the registry.
25    Compile,
26    /// No valid credential was presented.
27    Unauthorized,
28    /// The credential lacks the scope this route requires.
29    Forbidden,
30    /// No run exists for the requested id.
31    RunNotFound,
32    /// The concurrency cap is saturated; retry later.
33    TooManyRuns,
34    /// An unexpected server-side failure. Detail is logged, not returned.
35    Internal,
36}
37
38#[derive(Debug, Serialize)]
39struct ErrorBody {
40    error: ErrorCode,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    detail: Option<String>,
43}
44
45/// Every failure the run-service surfaces to a client.
46#[non_exhaustive]
47#[derive(Debug, thiserror::Error)]
48pub enum WorkflowApiError {
49    /// Malformed / semantically invalid request (e.g. a non-object input).
50    #[error("bad request: {0}")]
51    BadRequest(String),
52    /// The submitted definition did not compile against the registry. The
53    /// `CompileError` message is actionable authoring feedback and is safe
54    /// to return (it names ids, carries no stack trace or secret).
55    #[error("workflow does not compile: {0}")]
56    Compile(#[from] CompileError),
57    /// No valid credential was presented.
58    #[error("unauthorized")]
59    Unauthorized,
60    /// The credential lacks the required scope.
61    #[error("forbidden")]
62    Forbidden,
63    /// No run exists for the requested id.
64    #[error("run not found")]
65    RunNotFound,
66    /// The concurrency cap is saturated.
67    #[error("too many concurrent runs")]
68    TooManyRuns,
69    /// An unexpected server-side failure. The source is logged, never
70    /// returned to the client.
71    #[error("internal error")]
72    Internal(#[source] Box<dyn std::error::Error + Send + Sync>),
73}
74
75impl IntoResponse for WorkflowApiError {
76    fn into_response(self) -> Response {
77        let (status, error, detail) = match self {
78            Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, ErrorCode::BadRequest, Some(msg)),
79            Self::Compile(err) => (
80                StatusCode::BAD_REQUEST,
81                ErrorCode::Compile,
82                Some(err.to_string()),
83            ),
84            Self::Unauthorized => (StatusCode::UNAUTHORIZED, ErrorCode::Unauthorized, None),
85            Self::Forbidden => (StatusCode::FORBIDDEN, ErrorCode::Forbidden, None),
86            Self::RunNotFound => (StatusCode::NOT_FOUND, ErrorCode::RunNotFound, None),
87            Self::TooManyRuns => (StatusCode::TOO_MANY_REQUESTS, ErrorCode::TooManyRuns, None),
88            Self::Internal(source) => {
89                tracing::error!(error = %source, "workflow-api internal error");
90                (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::Internal, None)
91            }
92        };
93        let mut response = (status, Json(ErrorBody { error, detail })).into_response();
94        if status == StatusCode::TOO_MANY_REQUESTS {
95            response
96                .headers_mut()
97                .insert(RETRY_AFTER, HeaderValue::from_static(RETRY_AFTER_SECONDS));
98        }
99        response
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn variants_map_to_expected_status() {
109        assert_eq!(
110            WorkflowApiError::BadRequest("x".into())
111                .into_response()
112                .status(),
113            StatusCode::BAD_REQUEST
114        );
115        assert_eq!(
116            WorkflowApiError::Unauthorized.into_response().status(),
117            StatusCode::UNAUTHORIZED
118        );
119        assert_eq!(
120            WorkflowApiError::Forbidden.into_response().status(),
121            StatusCode::FORBIDDEN
122        );
123        assert_eq!(
124            WorkflowApiError::RunNotFound.into_response().status(),
125            StatusCode::NOT_FOUND
126        );
127        assert_eq!(
128            WorkflowApiError::Internal("boom".into())
129                .into_response()
130                .status(),
131            StatusCode::INTERNAL_SERVER_ERROR
132        );
133    }
134
135    #[test]
136    fn compile_error_maps_to_400() {
137        let err = WorkflowApiError::Compile(CompileError::MissingEntry("start".into()));
138        assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
139    }
140
141    #[test]
142    fn too_many_runs_sets_retry_after() {
143        let response = WorkflowApiError::TooManyRuns.into_response();
144        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
145        assert_eq!(
146            response.headers().get(RETRY_AFTER).unwrap(),
147            RETRY_AFTER_SECONDS
148        );
149    }
150
151    #[test]
152    fn error_code_serialises_snake_case() {
153        let json = serde_json::to_string(&ErrorBody {
154            error: ErrorCode::TooManyRuns,
155            detail: None,
156        })
157        .unwrap();
158        assert!(json.contains("too_many_runs"), "got: {json}");
159    }
160}