klieo_workflow_api/
error.rs1use axum::{
6 http::{header::RETRY_AFTER, HeaderValue, StatusCode},
7 response::{IntoResponse, Response},
8 Json,
9};
10use klieo_workflow::CompileError;
11use serde::Serialize;
12
13const RETRY_AFTER_SECONDS: &str = "1";
16
17#[non_exhaustive]
19#[derive(Debug, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum ErrorCode {
22 BadRequest,
24 Compile,
26 Unauthorized,
28 Forbidden,
30 RunNotFound,
32 TooManyRuns,
34 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#[non_exhaustive]
47#[derive(Debug, thiserror::Error)]
48pub enum WorkflowApiError {
49 #[error("bad request: {0}")]
51 BadRequest(String),
52 #[error("workflow does not compile: {0}")]
56 Compile(#[from] CompileError),
57 #[error("unauthorized")]
59 Unauthorized,
60 #[error("forbidden")]
62 Forbidden,
63 #[error("run not found")]
65 RunNotFound,
66 #[error("too many concurrent runs")]
68 TooManyRuns,
69 #[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}