use axum::{ Json, http::StatusCode, response::{IntoResponse, Response}, }; use crank_mapping::MappingError; use crank_registry::RegistryError; use crank_runtime::RuntimeError; use crank_schema::SchemaError; use serde_json::{Value, json}; use thiserror::Error; use tracing::{error, warn}; use crate::storage::StorageError; #[derive(Debug, Error)] pub enum ApiError { #[error("{message}")] Unauthorized { message: String, context: Option, }, #[error("{message}")] Forbidden { message: String, context: Option, }, #[error("{message}")] Validation { message: String, context: Option, }, #[error("{message}")] NotFound { message: String, context: Option, }, #[error("{message}")] Conflict { message: String, context: Option, }, #[error("{message}")] RateLimited { message: String, context: Option, }, #[error("{message}")] Internal { message: String, context: Option, }, } impl ApiError { pub fn unauthorized(message: impl Into) -> Self { Self::Unauthorized { message: message.into(), context: None, } } pub fn forbidden(message: impl Into) -> Self { Self::Forbidden { message: message.into(), context: None, } } pub fn validation(message: impl Into) -> Self { Self::Validation { message: message.into(), context: None, } } pub fn internal(message: impl Into) -> Self { Self::Internal { message: message.into(), context: None, } } pub(crate) fn rate_limited_with_context(message: impl Into, context: Value) -> Self { Self::RateLimited { message: message.into(), context: Some(context), } } pub(crate) fn validation_with_context(message: impl Into, context: Value) -> Self { Self::Validation { message: message.into(), context: Some(context), } } pub(crate) fn not_found_with_context(message: impl Into, context: Value) -> Self { Self::NotFound { message: message.into(), context: Some(context), } } pub(crate) fn conflict_with_context(message: impl Into, context: Value) -> Self { Self::Conflict { message: message.into(), context: Some(context), } } pub(crate) fn forbidden_with_context(message: impl Into, context: Value) -> Self { Self::Forbidden { message: message.into(), context: Some(context), } } fn status_code(&self) -> StatusCode { match self { Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED, Self::Forbidden { .. } => StatusCode::FORBIDDEN, Self::Validation { .. } => StatusCode::BAD_REQUEST, Self::NotFound { .. } => StatusCode::NOT_FOUND, Self::Conflict { .. } => StatusCode::CONFLICT, Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS, Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR, } } fn code(&self) -> &'static str { match self { Self::Unauthorized { .. } => "unauthorized", Self::Forbidden { .. } => "forbidden", Self::Validation { .. } => "validation_error", Self::NotFound { .. } => "not_found", Self::Conflict { .. } => "conflict", Self::RateLimited { .. } => "rate_limited", Self::Internal { .. } => "internal_error", } } } impl IntoResponse for ApiError { fn into_response(self) -> Response { match &self { Self::Internal { message, .. } => { error!(error_code = self.code(), error_message = %message) } Self::Unauthorized { message, .. } | Self::Forbidden { message, .. } | Self::Validation { message, .. } | Self::NotFound { message, .. } | Self::Conflict { message, .. } | Self::RateLimited { message, .. } => { warn!(error_code = self.code(), error_message = %message) } } let mut error = json!({ "code": self.code(), "message": self.to_string(), }); if let Some(context) = self.context() { error["context"] = context; } let body = Json(json!({ "error": error })); (self.status_code(), body).into_response() } } impl ApiError { fn context(&self) -> Option { match self { Self::Unauthorized { context, .. } | Self::Forbidden { context, .. } | Self::Validation { context, .. } | Self::NotFound { context, .. } | Self::Conflict { context, .. } | Self::RateLimited { context, .. } | Self::Internal { context, .. } => context.clone(), } } } impl From for ApiError { fn from(value: RegistryError) -> Self { match value { RegistryError::WorkspaceNotFound { workspace_id } => Self::not_found_with_context( format!("workspace {workspace_id} was not found"), json!({ "workspace_id": workspace_id }), ), RegistryError::UserNotFound { user_id } => Self::not_found_with_context( format!("user {user_id} was not found"), json!({ "user_id": user_id }), ), RegistryError::MembershipNotFound { workspace_id, user_id, } => Self::not_found_with_context( format!("membership for user {user_id} in workspace {workspace_id} was not found"), json!({ "workspace_id": workspace_id, "user_id": user_id, }), ), RegistryError::AgentNotFound { agent_id } => Self::not_found_with_context( format!("agent {agent_id} was not found"), json!({ "agent_id": agent_id }), ), RegistryError::InvitationNotFound { invitation_id } => Self::not_found_with_context( format!("invitation {invitation_id} was not found"), json!({ "invitation_id": invitation_id }), ), RegistryError::PlatformApiKeyNotFound { key_id } => Self::not_found_with_context( format!("platform api key {key_id} was not found"), json!({ "key_id": key_id }), ), RegistryError::SecretNotFound { secret_id } => Self::not_found_with_context( format!("secret {secret_id} was not found"), json!({ "secret_id": secret_id }), ), RegistryError::StreamSessionNotFound { session_id } => Self::not_found_with_context( format!("stream session {session_id} was not found"), json!({ "session_id": session_id }), ), RegistryError::AsyncJobNotFound { job_id } => Self::not_found_with_context( format!("async job {job_id} was not found"), json!({ "job_id": job_id }), ), RegistryError::InvocationLogNotFound { log_id } => Self::not_found_with_context( format!("invocation log {log_id} was not found"), json!({ "log_id": log_id }), ), RegistryError::PublishedAgentNotFound { workspace_slug, agent_slug, } => Self::not_found_with_context( format!("published agent {workspace_slug}/{agent_slug} was not found"), json!({ "workspace_slug": workspace_slug, "agent_slug": agent_slug, }), ), RegistryError::OperationNotFound { operation_id } => Self::not_found_with_context( format!("operation {operation_id} was not found"), json!({ "operation_id": operation_id }), ), RegistryError::OperationVersionNotFound { operation_id, version, } => Self::not_found_with_context( format!("operation version {version} for {operation_id} was not found"), json!({ "operation_id": operation_id, "version": version, }), ), RegistryError::OperationHasPublishedAgentBindings { operation_id } => { Self::conflict_with_context( format!( "operation {operation_id} cannot be deleted while it is bound to a published agent" ), json!({ "operation_id": operation_id }), ) } RegistryError::AuthProfileNotFound { auth_profile_id } => Self::not_found_with_context( format!("auth profile {auth_profile_id} was not found"), json!({ "auth_profile_id": auth_profile_id }), ), RegistryError::OperationAlreadyExists { operation_id } => Self::conflict_with_context( format!("operation {operation_id} already exists"), json!({ "operation_id": operation_id }), ), RegistryError::WorkspaceSlugAlreadyExists { slug } => Self::conflict_with_context( format!("workspace with slug {slug} already exists"), json!({ "slug": slug }), ), RegistryError::SecretNameAlreadyExists { workspace_id, name } => { Self::conflict_with_context( format!("secret with name {name} already exists in workspace {workspace_id}"), json!({ "workspace_id": workspace_id, "name": name, }), ) } RegistryError::SecretReferencedByAuthProfile { secret_id, auth_profile_id, } => Self::conflict_with_context( format!("secret {secret_id} is referenced by auth profile {auth_profile_id}"), json!({ "secret_id": secret_id, "auth_profile_id": auth_profile_id, }), ), RegistryError::InvalidStreamSessionTransition { session_id, from, to, } => Self::conflict_with_context( format!("invalid stream session transition for {session_id}: {from} -> {to}"), json!({ "session_id": session_id, "from": from, "to": to, }), ), RegistryError::InvalidAsyncJobTransition { job_id, from, to } => { Self::conflict_with_context( format!("invalid async job transition for {job_id}: {from} -> {to}"), json!({ "job_id": job_id, "from": from, "to": to, }), ) } RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context( format!("user with email {email} already exists"), json!({ "email": email }), ), RegistryError::InvalidInitialVersion { operation_id, version, } => Self::validation_with_context( format!("operation {operation_id} must start with version 1, got {version}"), json!({ "operation_id": operation_id, "version": version, }), ), RegistryError::InvalidVersionSequence { operation_id, expected, actual, } => Self::validation_with_context( format!("operation {operation_id} expected next version {expected}, got {actual}"), json!({ "operation_id": operation_id, "expected": expected, "actual": actual, }), ), RegistryError::ImmutableOperationFieldChanged { operation_id, field, } => Self::validation_with_context( format!("operation {operation_id} changed immutable field {field}"), json!({ "operation_id": operation_id, "field": field, }), ), RegistryError::InvalidEnumRepresentation { field } => Self::validation_with_context( format!("unsupported enum representation for field {field}"), json!({ "field": field }), ), RegistryError::InvalidNumericValue { field, value } => Self::validation_with_context( format!("invalid numeric value for field {field}: {value}"), json!({ "field": field, "value": value, }), ), RegistryError::YamlImportJobNotFound { job_id } => Self::validation_with_context( format!("yaml import job {job_id} was not found"), json!({ "job_id": job_id }), ), RegistryError::Storage(_) | RegistryError::Serialization(_) => { Self::internal(value.to_string()) } } } } impl From for ApiError { fn from(value: MappingError) -> Self { Self::validation(value.to_string()) } } impl From for ApiError { fn from(value: SchemaError) -> Self { Self::validation(value.to_string()) } } impl From for ApiError { fn from(value: StorageError) -> Self { match value { StorageError::InvalidStorageRef { details } => Self::validation_with_context( format!("invalid storage reference: {details}"), json!({ "details": details }), ), StorageError::Io(_) | StorageError::Serialization(_) => { Self::internal(value.to_string()) } } } } pub fn runtime_test_failure(error: &RuntimeError) -> Value { let mut payload = json!({ "code": runtime_test_failure_code(error), "message": error.to_string() }); if let Some(context) = runtime_error_context(error) { payload["context"] = context; } payload } fn runtime_test_failure_code(error: &RuntimeError) -> &'static str { match error { RuntimeError::Schema(_) => "runtime_schema_error", RuntimeError::Mapping(_) => "runtime_mapping_error", RuntimeError::GraphqlAdapter(_) => "runtime_graphql_error", RuntimeError::GrpcAdapter(_) => "runtime_grpc_error", RuntimeError::RestAdapter(_) => "runtime_rest_error", RuntimeError::SoapAdapter(_) => "runtime_soap_error", RuntimeError::WebsocketAdapter(_) => "runtime_websocket_error", RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error", RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded", RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error", RuntimeError::MissingStreamingConfig { .. } => "runtime_streaming_config_error", RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error", RuntimeError::InvalidStreamingPayload { .. } => "runtime_streaming_payload_error", RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error", RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => { "runtime_secret_error" } RuntimeError::InvalidAuthSecretValue { .. } => "runtime_secret_value_error", RuntimeError::SecretCrypto { .. } => "runtime_secret_crypto_error", } } pub fn runtime_error_context(error: &RuntimeError) -> Option { match error { RuntimeError::InvalidPreparedRequest { field, reason } => Some(json!({ "field": field, "reason": reason, })), RuntimeError::InvalidStreamingPayload { field, reason } => Some(json!({ "field": field, "reason": reason, })), RuntimeError::InvalidAuthSecretValue { secret_id, reason } => Some(json!({ "secret_id": secret_id, "reason": reason, })), RuntimeError::SecretCrypto { operation, details } => Some(json!({ "operation": operation, "details": details, })), RuntimeError::MissingAuthProfile { auth_profile_id } => Some(json!({ "auth_profile_id": auth_profile_id, })), RuntimeError::MissingSecret { secret_id } => Some(json!({ "secret_id": secret_id, })), RuntimeError::MissingSecretVersion { secret_id, version } => Some(json!({ "secret_id": secret_id, "version": version, })), RuntimeError::MissingStreamingConfig { operation_id } => Some(json!({ "operation_id": operation_id, })), RuntimeError::UnsupportedExecutionMode { operation_id, mode } => Some(json!({ "operation_id": operation_id, "mode": mode, })), RuntimeError::UnsupportedProtocol { protocol } => Some(json!({ "protocol": protocol, })), RuntimeError::ConcurrencyLimitExceeded { kind, limit } => Some(json!({ "kind": kind, "limit": limit, })), _ => None, } } #[cfg(test)] mod tests { use serde_json::json; use super::{ApiError, runtime_error_context, runtime_test_failure}; use crank_registry::RegistryError; use crank_runtime::RuntimeError; #[test] fn runtime_test_failure_includes_structured_context() { let payload = runtime_test_failure(&RuntimeError::InvalidPreparedRequest { field: "request.headers".to_owned(), reason: "must be an object".to_owned(), }); assert_eq!(payload["code"], "runtime_request_error"); assert_eq!( payload["context"], json!({ "field": "request.headers", "reason": "must be an object" }) ); } #[test] fn runtime_error_context_includes_secret_crypto_operation() { let context = runtime_error_context(&RuntimeError::SecretCrypto { operation: "decode secret envelope", details: "bad base64".to_owned(), }) .unwrap(); assert_eq!( context, json!({ "operation": "decode secret envelope", "details": "bad base64" }) ); } #[test] fn runtime_test_failure_includes_runtime_overload_context() { let payload = runtime_test_failure(&RuntimeError::ConcurrencyLimitExceeded { kind: "window", limit: 16, }); assert_eq!(payload["code"], "runtime_overloaded"); assert_eq!( payload["context"], json!({ "kind": "window", "limit": 16 }) ); } #[test] fn registry_errors_preserve_structured_context_in_api_error() { let error = ApiError::from(RegistryError::InvalidAsyncJobTransition { job_id: "job_123".to_owned(), from: "running".to_owned(), to: "completed".to_owned(), }); match error { ApiError::Conflict { context, .. } => { assert_eq!( context, Some(json!({ "job_id": "job_123", "from": "running", "to": "completed" })) ); } other => panic!("unexpected error variant: {other:?}"), } } }