use axum::{
http::{header::RETRY_AFTER, HeaderValue, StatusCode},
response::{IntoResponse, Response},
Json,
};
use klieo_workflow::CompileError;
use serde::Serialize;
const RETRY_AFTER_SECONDS: &str = "1";
#[non_exhaustive]
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
BadRequest,
Compile,
Unauthorized,
Forbidden,
RunNotFound,
TooManyRuns,
Internal,
}
#[derive(Debug, Serialize)]
struct ErrorBody {
error: ErrorCode,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum WorkflowApiError {
#[error("bad request: {0}")]
BadRequest(String),
#[error("workflow does not compile: {0}")]
Compile(#[from] CompileError),
#[error("unauthorized")]
Unauthorized,
#[error("forbidden")]
Forbidden,
#[error("run not found")]
RunNotFound,
#[error("too many concurrent runs")]
TooManyRuns,
#[error("internal error")]
Internal(#[source] Box<dyn std::error::Error + Send + Sync>),
}
impl IntoResponse for WorkflowApiError {
fn into_response(self) -> Response {
let (status, error, detail) = match self {
Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, ErrorCode::BadRequest, Some(msg)),
Self::Compile(err) => (
StatusCode::BAD_REQUEST,
ErrorCode::Compile,
Some(err.to_string()),
),
Self::Unauthorized => (StatusCode::UNAUTHORIZED, ErrorCode::Unauthorized, None),
Self::Forbidden => (StatusCode::FORBIDDEN, ErrorCode::Forbidden, None),
Self::RunNotFound => (StatusCode::NOT_FOUND, ErrorCode::RunNotFound, None),
Self::TooManyRuns => (StatusCode::TOO_MANY_REQUESTS, ErrorCode::TooManyRuns, None),
Self::Internal(source) => {
tracing::error!(error = %source, "workflow-api internal error");
(StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::Internal, None)
}
};
let mut response = (status, Json(ErrorBody { error, detail })).into_response();
if status == StatusCode::TOO_MANY_REQUESTS {
response
.headers_mut()
.insert(RETRY_AFTER, HeaderValue::from_static(RETRY_AFTER_SECONDS));
}
response
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn variants_map_to_expected_status() {
assert_eq!(
WorkflowApiError::BadRequest("x".into())
.into_response()
.status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
WorkflowApiError::Unauthorized.into_response().status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
WorkflowApiError::Forbidden.into_response().status(),
StatusCode::FORBIDDEN
);
assert_eq!(
WorkflowApiError::RunNotFound.into_response().status(),
StatusCode::NOT_FOUND
);
assert_eq!(
WorkflowApiError::Internal("boom".into())
.into_response()
.status(),
StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn compile_error_maps_to_400() {
let err = WorkflowApiError::Compile(CompileError::MissingEntry("start".into()));
assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
}
#[test]
fn too_many_runs_sets_retry_after() {
let response = WorkflowApiError::TooManyRuns.into_response();
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(
response.headers().get(RETRY_AFTER).unwrap(),
RETRY_AFTER_SECONDS
);
}
#[test]
fn error_code_serialises_snake_case() {
let json = serde_json::to_string(&ErrorBody {
error: ErrorCode::TooManyRuns,
detail: None,
})
.unwrap();
assert!(json.contains("too_many_runs"), "got: {json}");
}
}