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::dto::OpenApiUploadLocale; 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}")] Unprocessable { message: String, context: Option, }, #[error("{message}")] NotFound { message: String, context: Option, }, #[error("{message}")] Conflict { message: String, context: Option, }, #[error("{message}")] PayloadTooLarge { message: String, context: Option, }, #[error("{message}")] PreconditionRequired { 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: "internal server error".to_owned(), context: None, } } pub(crate) fn openapi_upload(locale: OpenApiUploadLocale, code: &'static str) -> Self { let russian = locale == OpenApiUploadLocale::Ru; let message = match (russian, code) { (_, "file_too_large") => { if russian { "файл OpenAPI превышает лимит 256 КиБ" } else { "OpenAPI file exceeds the 256 KiB limit" } } (_, "empty_file") => { if russian { "файл OpenAPI не должен быть пустым" } else { "OpenAPI file must not be empty" } } (_, "invalid_utf8") => { if russian { "файл OpenAPI должен быть в UTF-8" } else { "OpenAPI file must be UTF-8" } } (_, "invalid_media_type") => { if russian { "тип файла OpenAPI не поддерживается" } else { "OpenAPI file type is not supported" } } (_, "invalid_document") => { if russian { "некорректный или неподдерживаемый документ OpenAPI" } else { "OpenAPI document is invalid or unsupported" } } (_, "no_methods") => { if russian { "документ OpenAPI не содержит поддерживаемых методов" } else { "OpenAPI document contains no supported methods" } } (_, "source_integrity") => { if russian { "проверка целостности источника OpenAPI не пройдена" } else { "OpenAPI source integrity verification failed" } } (_, "source_unavailable") => { if russian { "источник OpenAPI недоступен" } else { "OpenAPI source is unavailable" } } (_, "parser_unavailable") | (_, "storage_unavailable") => { if russian { "обработка OpenAPI временно недоступна" } else { "OpenAPI processing is temporarily unavailable" } } _ => { if russian { "некорректная multipart-загрузка OpenAPI" } else { "invalid OpenAPI multipart upload" } } }; let context = json!({ "error_code": format!("openapi_upload.{code}") }); if code == "file_too_large" { Self::PayloadTooLarge { message: message.to_owned(), context: Some(context), } } else if matches!(code, "storage_unavailable" | "parser_unavailable") { Self::Internal { message: message.to_owned(), context: Some(context), } } else if matches!(code, "source_integrity" | "source_unavailable") { Self::Unprocessable { message: message.to_owned(), context: Some(context), } } else { Self::Validation { message: message.to_owned(), context: Some(context), } } } pub(crate) fn source_unavailable() -> Self { Self::openapi_upload(OpenApiUploadLocale::En, "source_unavailable") } pub(crate) fn source_integrity() -> Self { Self::openapi_upload(OpenApiUploadLocale::En, "source_integrity") } 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 unprocessable_with_context(message: impl Into, context: Value) -> Self { Self::Unprocessable { 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 payload_too_large_with_context( message: impl Into, context: Value, ) -> Self { Self::PayloadTooLarge { message: message.into(), context: Some(context), } } pub(crate) fn precondition_required_with_context( message: impl Into, context: Value, ) -> Self { Self::PreconditionRequired { 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::Unprocessable { .. } => StatusCode::UNPROCESSABLE_ENTITY, Self::NotFound { .. } => StatusCode::NOT_FOUND, Self::Conflict { .. } => StatusCode::CONFLICT, Self::PayloadTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE, Self::PreconditionRequired { .. } => StatusCode::PRECONDITION_REQUIRED, Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS, Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR, } } pub(crate) fn code(&self) -> &str { if let Some(code) = self .context_ref() .and_then(|context| context.get("error_code")) .and_then(Value::as_str) { return code; } match self { Self::Unauthorized { .. } => "unauthorized", Self::Forbidden { .. } => "forbidden", Self::Validation { .. } => "validation_error", Self::Unprocessable { .. } => "unprocessable_entity", Self::NotFound { .. } => "not_found", Self::Conflict { .. } => "conflict", Self::PayloadTooLarge { .. } => "payload_too_large", Self::PreconditionRequired { .. } => "precondition_required", Self::RateLimited { .. } => "rate_limited", Self::Internal { .. } => "internal_error", } } } impl IntoResponse for ApiError { fn into_response(self) -> Response { match &self { Self::Internal { .. } => { error!( name: "admin.response.internal_error", error_code = self.code(), "internal API error response" ) } Self::Unauthorized { .. } | Self::Forbidden { .. } | Self::Validation { .. } | Self::Unprocessable { .. } | Self::NotFound { .. } | Self::Conflict { .. } | Self::PayloadTooLarge { .. } | Self::PreconditionRequired { .. } | Self::RateLimited { .. } => { warn!( name: "admin.response.rejected", error_code = self.code(), "API request rejected" ) } } let mut error = json!({ "code": self.code(), "message": self.to_string(), }); if let Some(context) = self.context() { error["context"] = context; } let (request_id, trace_id) = crank_observability::current_request_correlation(); if let Some(request_id) = request_id { error["request_id"] = Value::String(request_id); } if let Some(trace_id) = trace_id { error["trace_id"] = Value::String(trace_id); } let body = Json(json!({ "error": error })); (self.status_code(), body).into_response() } } impl ApiError { fn context_ref(&self) -> Option<&Value> { match self { Self::Unauthorized { context, .. } | Self::Forbidden { context, .. } | Self::Validation { context, .. } | Self::Unprocessable { context, .. } | Self::NotFound { context, .. } | Self::Conflict { context, .. } | Self::PayloadTooLarge { context, .. } | Self::PreconditionRequired { context, .. } | Self::RateLimited { context, .. } | Self::Internal { context, .. } => context.as_ref(), } } fn context(&self) -> Option { self.context_ref().cloned() } } 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::PlatformApiKeyInactive { key_id } => Self::conflict_with_context( "platform api key is not active", json!({ "key_id": key_id, "error_code": "platform_api_key_not_active", "recovery": "create_replacement_key" }), ), RegistryError::SecretNotFound { secret_id } => Self::not_found_with_context( format!("secret {secret_id} was not found"), json!({ "secret_id": secret_id }), ), RegistryError::SecretInactive { secret_id } => Self::conflict_with_context( "secret is not active", json!({ "secret_id": secret_id, "error_code": "secret_not_active", "recovery": "rotate_or_replace_secret" }), ), RegistryError::SecretConcurrentUpdate { secret_id } => Self::conflict_with_context( "secret was updated concurrently", json!({ "secret_id": secret_id, "error_code": "secret_concurrent_update", "recovery": "reload" }), ), RegistryError::MasterKeyRotationInProgress => Self::conflict_with_context( "master key rotation is in progress", json!({ "error_code": "master_key_rotation_in_progress", "recovery": "retry_after_rotation" }), ), 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::OperationArchived { operation_id } => Self::conflict_with_context( format!("operation {operation_id} is archived"), json!({ "operation_id": operation_id, "error_code": "operation_archived" }), ), RegistryError::OperationStaleVersion { operation_id, expected, actual, } => Self::conflict_with_context( format!("operation {operation_id} has a stale base version"), json!({ "operation_id": operation_id, "current_version": expected, "provided_version": actual, "error_code": "operation_stale_version", "recovery": "reload" }), ), RegistryError::InvalidOperationTransition { operation_id, from, action, } => Self::conflict_with_context( format!("operation {operation_id} cannot perform {action} from {from}"), json!({ "operation_id": operation_id, "state": from, "action": action, "error_code": "operation_invalid_transition" }), ), RegistryError::OperationDeleteForbidden { operation_id } => { Self::conflict_with_context( format!( "operation {operation_id} cannot be deleted because durable history exists" ), json!({ "operation_id": operation_id, "error_code": "operation_delete_forbidden" }), ) } RegistryError::OperationAuthProfileUnavailable { operation_id } => { Self::unprocessable_with_context( "operation auth profile reference is unavailable", json!({ "operation_id": operation_id, "error_code": "operation_auth_profile_invalid" }), ) } 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::PlatformApiKeyNameAlreadyExists { workspace_id, name } => { Self::conflict_with_context( "platform api key name already exists", json!({ "workspace_id": workspace_id, "name": name, "error_code": "platform_api_key_name_conflict", "recovery": "choose_different_name" }), ) } 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, "error_code": "secret_name_conflict", "recovery": "choose_different_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, "error_code": "secret_referenced_by_auth_profile", "recovery": "remove_or_update_auth_profile_reference" }), ), RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context( format!("user with email {email} already exists"), json!({ "email": email }), ), RegistryError::AdminBootstrapUnavailable | RegistryError::AdminBootstrapRejected => { Self::unauthorized("bootstrap request is invalid or expired") } RegistryError::AdminRecoveryRejected => Self::unauthorized("recovery request rejected"), RegistryError::AdminLoginRateLimited { retry_after_ms } => { Self::rate_limited_with_context( "login temporarily unavailable", json!({ "retry_after_ms": retry_after_ms.clamp(1, 300_000), "error_code": "login_throttled", "recovery": "retry_after_delay" }), ) } RegistryError::AdminCsrfRejected => Self::forbidden("csrf validation failed"), 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::InvalidAgentVersionSequence { agent_id, expected, actual, } => Self::validation_with_context( format!("agent {agent_id} expected next version {expected}, got {actual}"), json!({ "agent_id": agent_id, "expected": expected, "actual": actual, }), ), RegistryError::ImmutableAgentVersion { agent_id, version } => { Self::conflict_with_context( "Agent Version is immutable; reload before editing", json!({ "agent_id": agent_id, "version": version, "error_code": "agent_stale_revision", "recovery": "reload" }), ) } RegistryError::AgentStaleRevision { agent_id } => Self::conflict_with_context( "Agent state changed; reload before retrying", json!({ "agent_id": agent_id, "error_code": "agent_stale_revision", "recovery": "reload" }), ), RegistryError::InvalidAgentTransition { agent_id, from, action, } => Self::conflict_with_context( format!("agent {agent_id} cannot transition from {from} using {action}"), json!({ "agent_id": agent_id, "from": from, "action": action, "error_code": "agent_invalid_transition", "recovery": "reload" }), ), 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::OnboardingStaleRevision => Self::conflict_with_context( "onboarding state changed; reload before retrying", json!({"error_code":"onboarding_stale_revision","recovery":"reload"}), ), RegistryError::OnboardingIncomplete => Self::unprocessable_with_context( "onboarding domain steps are not complete", json!({"error_code":"onboarding_incomplete"}), ), 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::ImportJobNotFound { job_id } => Self::not_found_with_context( format!("import job {job_id} was not found"), json!({ "job_id": job_id }), ), RegistryError::ImportJobAlreadyApplied { job_id } => Self::conflict_with_context( format!("import job {job_id} was already applied with different parameters"), json!({ "job_id": job_id }), ), RegistryError::SourceNotFound { .. } => Self::not_found_with_context( "artifact source was not found", json!({ "error_code": "artifact_source_not_found" }), ), RegistryError::SourceConflict { .. } => Self::conflict_with_context( "artifact source metadata or lifecycle conflicts with the request", json!({ "error_code": "artifact_source_conflict", "recovery": "reload" }), ), RegistryError::SourceUnavailable => Self::unprocessable_with_context( "artifact source is unavailable", json!({ "error_code": "artifact_source_unavailable" }), ), RegistryError::SourceIntegrity => Self::unprocessable_with_context( "artifact source failed integrity verification", json!({ "error_code": "artifact_source_integrity" }), ), RegistryError::ArtifactClaimInProgress => Self::conflict_with_context( "artifact reconciliation is in progress", json!({ "error_code": "artifact_claim_in_progress", "recovery": "retry" }), ), RegistryError::InvalidArtifactSource { field } => Self::validation_with_context( "artifact source metadata is invalid", json!({ "field": field, "error_code": "artifact_source_invalid" }), ), RegistryError::Storage(_) => Self::internal("registry operation failed"), RegistryError::Migration(_) | RegistryError::Serialization(_) | RegistryError::MasterKeyIdentityMismatch { .. } | RegistryError::InvalidMasterKeyIdentity | RegistryError::MasterKeyRotationNotFound { .. } | RegistryError::MasterKeyRotationConflict | RegistryError::MasterKeyRotationVerificationFailed | RegistryError::InvalidCorrelationIdentity { .. } | RegistryError::InvalidExecutionRecord { .. } => { Self::internal("registry operation failed") } } } } 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 failure = crank_runtime::normalize_runtime_error(error, &crank_core::CorrelationContext::generate()); execution_test_failure(&failure) } pub fn execution_test_failure(failure: &crank_core::ExecutionFailure) -> Value { execution_test_failure_localized(failure, crank_core::ExecutionLocale::En) } pub fn execution_test_failure_localized( failure: &crank_core::ExecutionFailure, locale: crank_core::ExecutionLocale, ) -> Value { let mut payload = json!({ "code": failure.error_code().as_str(), "message": failure.error_code().message(locale), "stage": failure.stage().as_str(), "retryability": failure.retryability().as_str(), "outcome_certainty": failure.outcome_certainty().as_str(), }); if let Some(status) = failure.upstream_status() { payload["context"] = json!({ "upstream_status": status }); } if let Some(challenge) = failure.confirmation() { payload["context"] = json!({ "confirmation_token": challenge.token(), "expires_in_ms": challenge.expires_in_ms(), }); } payload }