api: finish structured error normalization

This commit is contained in:
a.tolmachev
2026-04-19 22:04:20 +00:00
parent b9ab17d1d3
commit f3f6a3b702
3 changed files with 87 additions and 57 deletions
+5 -5
View File
@@ -2,18 +2,18 @@
## Current
### `feat/error-structure-normalization`
### `feat/correlation-id-propagation`
Status: in_progress
DoD:
- runtime and API errors expose structured context where stable fields exist
- secret/runtime failure classes are mappable without parsing ad hoc message text
- opaque third-party details remain only where no stable structure exists
- admin-api, mcp-server, runtime, and adapters share a propagated correlation id
- request-scoped logs can be joined across layers without guessing from timestamps
- outbound adapter calls include correlation metadata where transport allows it
## Next
- `feat/correlation-id-propagation`
- `feat/runtime-rate-limiting-and-backpressure`
## Backlog
-14
View File
@@ -69,20 +69,6 @@ impl ApiError {
}
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::NotFound {
message: message.into(),
context: None,
}
}
pub fn conflict(message: impl Into<String>) -> Self {
Self::Conflict {
message: message.into(),
context: None,
}
}
pub fn internal(message: impl Into<String>) -> Self {
Self::Internal {
message: message.into(),
+82 -38
View File
@@ -961,7 +961,12 @@ impl AdminService {
.registry
.get_auth_user_by_id(user_id)
.await?
.ok_or_else(|| ApiError::not_found(format!("user {} was not found", user_id.as_str())))?
.ok_or_else(|| {
ApiError::not_found_with_context(
format!("user {} was not found", user_id.as_str()),
json!({ "user_id": user_id.as_str() }),
)
})?
.user;
let memberships = self.registry.list_workspaces_for_user(user_id).await?;
@@ -988,7 +993,10 @@ impl AdminService {
.get_auth_user_by_id(user_id)
.await?
.ok_or_else(|| {
ApiError::not_found(format!("user {} was not found", user_id.as_str()))
ApiError::not_found_with_context(
format!("user {} was not found", user_id.as_str()),
json!({ "user_id": user_id.as_str() }),
)
})?;
if !verify_password(
@@ -1016,7 +1024,10 @@ impl AdminService {
.get_workspace(workspace_id)
.await?
.ok_or_else(|| {
ApiError::not_found(format!("workspace {} was not found", workspace_id.as_str()))
ApiError::not_found_with_context(
format!("workspace {} was not found", workspace_id.as_str()),
json!({ "workspace_id": workspace_id.as_str() }),
)
})
}
@@ -1071,11 +1082,17 @@ impl AdminService {
.iter()
.find(|membership| &membership.user.id == target_user_id)
.ok_or_else(|| {
ApiError::not_found(format!(
"membership for user {} in workspace {} was not found",
target_user_id.as_str(),
workspace_id.as_str()
))
ApiError::not_found_with_context(
format!(
"membership for user {} in workspace {} was not found",
target_user_id.as_str(),
workspace_id.as_str()
),
json!({
"workspace_id": workspace_id.as_str(),
"user_id": target_user_id.as_str(),
}),
)
})?;
if !matches!(
@@ -1122,11 +1139,17 @@ impl AdminService {
.iter()
.find(|membership| &membership.user.id == target_user_id)
.ok_or_else(|| {
ApiError::not_found(format!(
"membership for user {} in workspace {} was not found",
target_user_id.as_str(),
workspace_id.as_str()
))
ApiError::not_found_with_context(
format!(
"membership for user {} in workspace {} was not found",
target_user_id.as_str(),
workspace_id.as_str()
),
json!({
"workspace_id": workspace_id.as_str(),
"user_id": target_user_id.as_str(),
}),
)
})?;
if !matches!(
@@ -1826,10 +1849,10 @@ impl AdminService {
.await?
.is_some()
{
return Err(ApiError::conflict(format!(
"operation with name {} already exists",
payload.name
)));
return Err(ApiError::conflict_with_context(
format!("operation with name {} already exists", payload.name),
json!({ "name": payload.name }),
));
}
let now = OffsetDateTime::now_utc();
@@ -1888,7 +1911,10 @@ impl AdminService {
.get_operation_summary(workspace_id, operation_id)
.await?
.ok_or_else(|| {
ApiError::not_found(format!("operation {} was not found", operation_id.as_str()))
ApiError::not_found_with_context(
format!("operation {} was not found", operation_id.as_str()),
json!({ "operation_id": operation_id.as_str() }),
)
})?;
let now = OffsetDateTime::now_utc();
let version = summary.current_draft_version + 1;
@@ -2757,10 +2783,10 @@ impl AdminService {
.await?
.is_some()
{
return Err(ApiError::conflict(format!(
"agent with slug {} already exists",
payload.slug
)));
return Err(ApiError::conflict_with_context(
format!("agent with slug {} already exists", payload.slug),
json!({ "slug": payload.slug }),
));
}
let now = OffsetDateTime::now_utc();
@@ -2816,7 +2842,10 @@ impl AdminService {
.get_agent_summary(workspace_id, agent_id)
.await?
.ok_or_else(|| {
ApiError::not_found(format!("agent {} was not found", agent_id.as_str()))
ApiError::not_found_with_context(
format!("agent {} was not found", agent_id.as_str()),
json!({ "agent_id": agent_id.as_str() }),
)
})?;
if payload.slug != existing.slug
@@ -2825,10 +2854,10 @@ impl AdminService {
.await?
.is_some()
{
return Err(ApiError::conflict(format!(
"agent with slug {} already exists",
payload.slug
)));
return Err(ApiError::conflict_with_context(
format!("agent with slug {} already exists", payload.slug),
json!({ "slug": payload.slug }),
));
}
let updated_at = OffsetDateTime::now_utc();
@@ -2861,7 +2890,10 @@ impl AdminService {
.get_agent_summary(workspace_id, agent_id)
.await?
.ok_or_else(|| {
ApiError::not_found(format!("agent {} was not found", agent_id.as_str()))
ApiError::not_found_with_context(
format!("agent {} was not found", agent_id.as_str()),
json!({ "agent_id": agent_id.as_str() }),
)
})?;
self.registry.delete_agent(workspace_id, agent_id).await?;
@@ -4924,11 +4956,17 @@ fn ensure_stream_session_workspace(
return Ok(());
}
Err(ApiError::not_found(format!(
"stream session {} was not found in workspace {}",
session.id.as_str(),
workspace_id.as_str()
)))
Err(ApiError::not_found_with_context(
format!(
"stream session {} was not found in workspace {}",
session.id.as_str(),
workspace_id.as_str()
),
json!({
"session_id": session.id.as_str(),
"workspace_id": workspace_id.as_str(),
}),
))
}
fn ensure_async_job_workspace(
@@ -4939,11 +4977,17 @@ fn ensure_async_job_workspace(
return Ok(());
}
Err(ApiError::not_found(format!(
"async job {} was not found in workspace {}",
job.id.as_str(),
workspace_id.as_str()
)))
Err(ApiError::not_found_with_context(
format!(
"async job {} was not found in workspace {}",
job.id.as_str(),
workspace_id.as_str()
),
json!({
"job_id": job.id.as_str(),
"workspace_id": workspace_id.as_str(),
}),
))
}
fn usage_window(period: UsagePeriod) -> Result<(UsagePeriod, String, UsageBucket), ApiError> {