api: preserve structured context for registry errors
This commit is contained in:
+274
-84
@@ -16,53 +16,98 @@ use crate::storage::StorageError;
|
|||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum ApiError {
|
pub enum ApiError {
|
||||||
#[error("{message}")]
|
#[error("{message}")]
|
||||||
Unauthorized { message: String },
|
Unauthorized {
|
||||||
|
message: String,
|
||||||
|
context: Option<Value>,
|
||||||
|
},
|
||||||
#[error("{message}")]
|
#[error("{message}")]
|
||||||
Forbidden { message: String },
|
Forbidden {
|
||||||
|
message: String,
|
||||||
|
context: Option<Value>,
|
||||||
|
},
|
||||||
#[error("{message}")]
|
#[error("{message}")]
|
||||||
Validation { message: String },
|
Validation {
|
||||||
|
message: String,
|
||||||
|
context: Option<Value>,
|
||||||
|
},
|
||||||
#[error("{message}")]
|
#[error("{message}")]
|
||||||
NotFound { message: String },
|
NotFound {
|
||||||
|
message: String,
|
||||||
|
context: Option<Value>,
|
||||||
|
},
|
||||||
#[error("{message}")]
|
#[error("{message}")]
|
||||||
Conflict { message: String },
|
Conflict {
|
||||||
|
message: String,
|
||||||
|
context: Option<Value>,
|
||||||
|
},
|
||||||
#[error("{message}")]
|
#[error("{message}")]
|
||||||
Internal { message: String },
|
Internal {
|
||||||
|
message: String,
|
||||||
|
context: Option<Value>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiError {
|
impl ApiError {
|
||||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||||
Self::Unauthorized {
|
Self::Unauthorized {
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
|
context: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn forbidden(message: impl Into<String>) -> Self {
|
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||||
Self::Forbidden {
|
Self::Forbidden {
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
|
context: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn validation(message: impl Into<String>) -> Self {
|
pub fn validation(message: impl Into<String>) -> Self {
|
||||||
Self::Validation {
|
Self::Validation {
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
|
context: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn not_found(message: impl Into<String>) -> Self {
|
pub fn not_found(message: impl Into<String>) -> Self {
|
||||||
Self::NotFound {
|
Self::NotFound {
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
|
context: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn conflict(message: impl Into<String>) -> Self {
|
pub fn conflict(message: impl Into<String>) -> Self {
|
||||||
Self::Conflict {
|
Self::Conflict {
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
|
context: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn internal(message: impl Into<String>) -> Self {
|
pub fn internal(message: impl Into<String>) -> Self {
|
||||||
Self::Internal {
|
Self::Internal {
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
|
context: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validation_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||||
|
Self::Validation {
|
||||||
|
message: message.into(),
|
||||||
|
context: Some(context),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn not_found_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||||
|
Self::NotFound {
|
||||||
|
message: message.into(),
|
||||||
|
context: Some(context),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conflict_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||||
|
Self::Conflict {
|
||||||
|
message: message.into(),
|
||||||
|
context: Some(context),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,114 +137,232 @@ impl ApiError {
|
|||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
match &self {
|
match &self {
|
||||||
Self::Internal { message } => {
|
Self::Internal { message, .. } => {
|
||||||
error!(error_code = self.code(), error_message = %message)
|
error!(error_code = self.code(), error_message = %message)
|
||||||
}
|
}
|
||||||
Self::Unauthorized { message }
|
Self::Unauthorized { message, .. }
|
||||||
| Self::Forbidden { message }
|
| Self::Forbidden { message, .. }
|
||||||
| Self::Validation { message }
|
| Self::Validation { message, .. }
|
||||||
| Self::NotFound { message }
|
| Self::NotFound { message, .. }
|
||||||
| Self::Conflict { message } => {
|
| Self::Conflict { message, .. } => {
|
||||||
warn!(error_code = self.code(), error_message = %message)
|
warn!(error_code = self.code(), error_message = %message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = Json(json!({
|
let mut error = json!({
|
||||||
"error": {
|
|
||||||
"code": self.code(),
|
"code": self.code(),
|
||||||
"message": self.to_string(),
|
"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()
|
(self.status_code(), body).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ApiError {
|
||||||
|
fn context(&self) -> Option<Value> {
|
||||||
|
match self {
|
||||||
|
Self::Unauthorized { context, .. }
|
||||||
|
| Self::Forbidden { context, .. }
|
||||||
|
| Self::Validation { context, .. }
|
||||||
|
| Self::NotFound { context, .. }
|
||||||
|
| Self::Conflict { context, .. }
|
||||||
|
| Self::Internal { context, .. } => context.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<RegistryError> for ApiError {
|
impl From<RegistryError> for ApiError {
|
||||||
fn from(value: RegistryError) -> Self {
|
fn from(value: RegistryError) -> Self {
|
||||||
match value {
|
match value {
|
||||||
RegistryError::WorkspaceNotFound { workspace_id } => {
|
RegistryError::WorkspaceNotFound { workspace_id } => Self::not_found_with_context(
|
||||||
Self::not_found(format!("workspace {workspace_id} was not found"))
|
format!("workspace {workspace_id} was not found"),
|
||||||
}
|
json!({ "workspace_id": workspace_id }),
|
||||||
RegistryError::UserNotFound { user_id } => {
|
),
|
||||||
Self::not_found(format!("user {user_id} was not found"))
|
RegistryError::UserNotFound { user_id } => Self::not_found_with_context(
|
||||||
}
|
format!("user {user_id} was not found"),
|
||||||
|
json!({ "user_id": user_id }),
|
||||||
|
),
|
||||||
RegistryError::MembershipNotFound {
|
RegistryError::MembershipNotFound {
|
||||||
workspace_id,
|
workspace_id,
|
||||||
user_id,
|
user_id,
|
||||||
} => Self::not_found(format!(
|
} => Self::not_found_with_context(
|
||||||
"membership for user {user_id} in workspace {workspace_id} was not found"
|
format!("membership for user {user_id} in workspace {workspace_id} was not found"),
|
||||||
)),
|
json!({
|
||||||
RegistryError::AgentNotFound { agent_id } => {
|
"workspace_id": workspace_id,
|
||||||
Self::not_found(format!("agent {agent_id} was not found"))
|
"user_id": user_id,
|
||||||
}
|
}),
|
||||||
RegistryError::InvitationNotFound { invitation_id } => {
|
),
|
||||||
Self::not_found(format!("invitation {invitation_id} was not found"))
|
RegistryError::AgentNotFound { agent_id } => Self::not_found_with_context(
|
||||||
}
|
format!("agent {agent_id} was not found"),
|
||||||
RegistryError::PlatformApiKeyNotFound { key_id } => {
|
json!({ "agent_id": agent_id }),
|
||||||
Self::not_found(format!("platform api key {key_id} was not found"))
|
),
|
||||||
}
|
RegistryError::InvitationNotFound { invitation_id } => Self::not_found_with_context(
|
||||||
RegistryError::SecretNotFound { secret_id } => {
|
format!("invitation {invitation_id} was not found"),
|
||||||
Self::not_found(format!("secret {secret_id} was not found"))
|
json!({ "invitation_id": invitation_id }),
|
||||||
}
|
),
|
||||||
RegistryError::StreamSessionNotFound { session_id } => {
|
RegistryError::PlatformApiKeyNotFound { key_id } => Self::not_found_with_context(
|
||||||
Self::not_found(format!("stream session {session_id} was not found"))
|
format!("platform api key {key_id} was not found"),
|
||||||
}
|
json!({ "key_id": key_id }),
|
||||||
RegistryError::AsyncJobNotFound { job_id } => {
|
),
|
||||||
Self::not_found(format!("async job {job_id} was not found"))
|
RegistryError::SecretNotFound { secret_id } => Self::not_found_with_context(
|
||||||
}
|
format!("secret {secret_id} was not found"),
|
||||||
RegistryError::InvocationLogNotFound { log_id } => {
|
json!({ "secret_id": secret_id }),
|
||||||
Self::not_found(format!("invocation log {log_id} was not found"))
|
),
|
||||||
}
|
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 {
|
RegistryError::PublishedAgentNotFound {
|
||||||
workspace_slug,
|
workspace_slug,
|
||||||
agent_slug,
|
agent_slug,
|
||||||
} => Self::not_found(format!(
|
} => Self::not_found_with_context(
|
||||||
"published agent {workspace_slug}/{agent_slug} was not found"
|
format!("published agent {workspace_slug}/{agent_slug} was not found"),
|
||||||
)),
|
json!({
|
||||||
RegistryError::OperationNotFound { operation_id } => {
|
"workspace_slug": workspace_slug,
|
||||||
Self::not_found(format!("operation {operation_id} was not found"))
|
"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 {
|
RegistryError::OperationVersionNotFound {
|
||||||
operation_id,
|
operation_id,
|
||||||
version,
|
version,
|
||||||
} => Self::not_found(format!(
|
} => Self::not_found_with_context(
|
||||||
"operation version {version} for {operation_id} was not found"
|
format!("operation version {version} for {operation_id} was not found"),
|
||||||
)),
|
json!({
|
||||||
RegistryError::OperationHasPublishedAgentBindings { operation_id } => {
|
"operation_id": operation_id,
|
||||||
Self::conflict(format!(
|
"version": version,
|
||||||
"operation {operation_id} cannot be deleted while it is bound to a published agent"
|
}),
|
||||||
))
|
|
||||||
}
|
|
||||||
RegistryError::AuthProfileNotFound { auth_profile_id } => {
|
|
||||||
Self::not_found(format!("auth profile {auth_profile_id} was not found"))
|
|
||||||
}
|
|
||||||
RegistryError::OperationAlreadyExists { operation_id } => {
|
|
||||||
Self::conflict(format!("operation {operation_id} already exists"))
|
|
||||||
}
|
|
||||||
RegistryError::WorkspaceSlugAlreadyExists { slug } => {
|
|
||||||
Self::conflict(format!("workspace with slug {slug} already exists"))
|
|
||||||
}
|
|
||||||
RegistryError::SecretNameAlreadyExists { workspace_id, name } => Self::conflict(
|
|
||||||
format!("secret with name {name} already exists in workspace {workspace_id}"),
|
|
||||||
),
|
),
|
||||||
|
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 {
|
RegistryError::SecretReferencedByAuthProfile {
|
||||||
secret_id,
|
secret_id,
|
||||||
auth_profile_id,
|
auth_profile_id,
|
||||||
} => Self::conflict(format!(
|
} => Self::conflict_with_context(
|
||||||
"secret {secret_id} is referenced by auth profile {auth_profile_id}"
|
format!("secret {secret_id} is referenced by auth profile {auth_profile_id}"),
|
||||||
)),
|
json!({
|
||||||
RegistryError::InvalidStreamSessionTransition { .. }
|
"secret_id": secret_id,
|
||||||
| RegistryError::InvalidAsyncJobTransition { .. } => Self::conflict(value.to_string()),
|
"auth_profile_id": auth_profile_id,
|
||||||
RegistryError::UserEmailAlreadyExists { email } => {
|
}),
|
||||||
Self::conflict(format!("user with email {email} already exists"))
|
),
|
||||||
|
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::InvalidInitialVersion { .. }
|
RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context(
|
||||||
| RegistryError::InvalidVersionSequence { .. }
|
format!("user with email {email} already exists"),
|
||||||
| RegistryError::ImmutableOperationFieldChanged { .. }
|
json!({ "email": email }),
|
||||||
| RegistryError::InvalidEnumRepresentation { .. }
|
),
|
||||||
| RegistryError::InvalidNumericValue { .. }
|
RegistryError::InvalidInitialVersion {
|
||||||
| RegistryError::YamlImportJobNotFound { .. } => Self::validation(value.to_string()),
|
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(_) => {
|
RegistryError::Storage(_) | RegistryError::Serialization(_) => {
|
||||||
Self::internal(value.to_string())
|
Self::internal(value.to_string())
|
||||||
}
|
}
|
||||||
@@ -222,7 +385,10 @@ impl From<SchemaError> for ApiError {
|
|||||||
impl From<StorageError> for ApiError {
|
impl From<StorageError> for ApiError {
|
||||||
fn from(value: StorageError) -> Self {
|
fn from(value: StorageError) -> Self {
|
||||||
match value {
|
match value {
|
||||||
StorageError::InvalidStorageRef { details } => Self::validation(details),
|
StorageError::InvalidStorageRef { details } => Self::validation_with_context(
|
||||||
|
format!("invalid storage reference: {details}"),
|
||||||
|
json!({ "details": details }),
|
||||||
|
),
|
||||||
StorageError::Io(_) | StorageError::Serialization(_) => {
|
StorageError::Io(_) | StorageError::Serialization(_) => {
|
||||||
Self::internal(value.to_string())
|
Self::internal(value.to_string())
|
||||||
}
|
}
|
||||||
@@ -310,7 +476,8 @@ pub fn runtime_error_context(error: &RuntimeError) -> Option<Value> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use super::{runtime_error_context, runtime_test_failure};
|
use super::{ApiError, runtime_error_context, runtime_test_failure};
|
||||||
|
use crank_registry::RegistryError;
|
||||||
use crank_runtime::RuntimeError;
|
use crank_runtime::RuntimeError;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -346,4 +513,27 @@ mod tests {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user