klieo-workflow-api 3.3.0

Embeddable Axum run-service router fronting klieo-workflow (submit / poll declarative workflows).
Documentation
//! Typed API failures and their HTTP mapping. Client-facing responses carry
//! a stable snake_case code (plus an actionable detail for authoring errors);
//! internal detail is logged server-side, never returned.

use axum::{
    http::{header::RETRY_AFTER, HeaderValue, StatusCode},
    response::{IntoResponse, Response},
    Json,
};
use klieo_workflow::CompileError;
use serde::Serialize;

/// Coarse hint (seconds) returned in `Retry-After` when the concurrency cap
/// is saturated. Deliberately small — capacity frees as in-flight runs finish.
const RETRY_AFTER_SECONDS: &str = "1";

/// Stable error codes returned in the JSON body of every non-2xx response.
#[non_exhaustive]
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
    /// The request body was malformed or semantically invalid.
    BadRequest,
    /// The submitted `WorkflowDef` failed to compile against the registry.
    Compile,
    /// No valid credential was presented.
    Unauthorized,
    /// The credential lacks the scope this route requires.
    Forbidden,
    /// No run exists for the requested id.
    RunNotFound,
    /// The concurrency cap is saturated; retry later.
    TooManyRuns,
    /// An unexpected server-side failure. Detail is logged, not returned.
    Internal,
}

#[derive(Debug, Serialize)]
struct ErrorBody {
    error: ErrorCode,
    #[serde(skip_serializing_if = "Option::is_none")]
    detail: Option<String>,
}

/// Every failure the run-service surfaces to a client.
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum WorkflowApiError {
    /// Malformed / semantically invalid request (e.g. a non-object input).
    #[error("bad request: {0}")]
    BadRequest(String),
    /// The submitted definition did not compile against the registry. The
    /// `CompileError` message is actionable authoring feedback and is safe
    /// to return (it names ids, carries no stack trace or secret).
    #[error("workflow does not compile: {0}")]
    Compile(#[from] CompileError),
    /// No valid credential was presented.
    #[error("unauthorized")]
    Unauthorized,
    /// The credential lacks the required scope.
    #[error("forbidden")]
    Forbidden,
    /// No run exists for the requested id.
    #[error("run not found")]
    RunNotFound,
    /// The concurrency cap is saturated.
    #[error("too many concurrent runs")]
    TooManyRuns,
    /// An unexpected server-side failure. The source is logged, never
    /// returned to the client.
    #[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}");
    }
}