use toolkit_canonical_errors::{CanonicalError, resource_error};
use crate::domain::error::{DomainError, reasons};
#[resource_error(gts_id!("cf.core.graph.node.v1~"))]
pub struct GraphNodeError;
fn violation_field(item: &graph_storage_sdk::models::ItemError) -> String {
let family = match item.family {
graph_storage_sdk::models::ItemFamily::Node => "nodes",
graph_storage_sdk::models::ItemFamily::Edge => "edges",
};
format!(
"{family}[{}]{}",
item.index,
item.pointer.as_deref().unwrap_or("")
)
}
fn validation_error(items: &[graph_storage_sdk::models::ItemError]) -> CanonicalError {
let Some((first, rest)) = items.split_first() else {
return GraphNodeError::invalid_argument()
.with_field_violation("request", "validation failed", reasons::SCHEMA_VIOLATION)
.create();
};
let mut builder = GraphNodeError::invalid_argument().with_field_violation(
violation_field(first),
first.message.clone(),
reasons::SCHEMA_VIOLATION,
);
for item in rest {
builder = builder.with_field_violation(
violation_field(item),
item.message.clone(),
reasons::SCHEMA_VIOLATION,
);
}
builder.create()
}
fn client_correctable(error: DomainError) -> Result<CanonicalError, DomainError> {
Ok(match error {
DomainError::Validation { items } => validation_error(&items),
DomainError::InvalidArgument { message } => GraphNodeError::invalid_argument()
.with_field_violation("request", message, reasons::INVALID_ARGUMENT)
.create(),
DomainError::LimitCombination { message } => GraphNodeError::invalid_argument()
.with_field_violation("request", message, reasons::LIMIT_COMBINATION)
.create(),
DomainError::InvalidQuery { message } => GraphNodeError::invalid_argument()
.with_field_violation("query", message, reasons::SCHEMA_VIOLATION)
.create(),
DomainError::LimitExceeded { what } => GraphNodeError::out_of_range(what.clone())
.with_field_violation("limit", what, reasons::LIMIT_EXCEEDED)
.create(),
DomainError::CasConflict { reason } => GraphNodeError::aborted(reason)
.with_reason(reasons::CAS_CONFLICT)
.create(),
DomainError::Serialization => {
GraphNodeError::aborted("serialization failure under concurrent ingest")
.with_reason(reasons::SERIALIZATION)
.create()
}
DomainError::StaleGeneration { recorded, offered } => GraphNodeError::failed_precondition()
.with_precondition_violation(
"source_generation",
format!("generation {offered} is older than the recorded {recorded}"),
reasons::STALE_GENERATION,
)
.create(),
DomainError::IdempotencyMismatch => {
GraphNodeError::aborted("idempotency key reused with a different request")
.with_reason(reasons::IDEMPOTENCY_MISMATCH)
.create()
}
DomainError::IdempotencyExpired => GraphNodeError::failed_precondition()
.with_precondition_violation(
"idempotency_key",
"receipt expired; reconcile and issue a new logical request",
reasons::IDEMPOTENCY_KEY_EXPIRED,
)
.create(),
other => return Err(other),
})
}
fn routing_outcome(error: DomainError) -> Result<CanonicalError, DomainError> {
Ok(match error {
DomainError::NotFound | DomainError::AccessDenied => GraphNodeError::not_found("not found")
.with_resource("")
.create(),
DomainError::ScopeUnservable { reason } => GraphNodeError::failed_precondition()
.with_precondition_violation("scope", reason, reasons::SCOPE_UNSERVABLE)
.create(),
DomainError::VectorSearchUnavailable { reason } => GraphNodeError::failed_precondition()
.with_precondition_violation(
"embedding_space",
reason,
reasons::EMBEDDING_SPACE_MISMATCH,
)
.create(),
DomainError::SourceNamespaceForbidden { namespace } => {
tracing::info!(
namespace = %namespace,
"refused a write under a source namespace owned by another producer"
);
GraphNodeError::permission_denied()
.with_reason(reasons::SOURCE_NAMESPACE_FORBIDDEN)
.create()
}
DomainError::Unsupported { what } => GraphNodeError::unimplemented(what).create(),
other => return Err(other),
})
}
fn unavailable(detail: &str) -> CanonicalError {
tracing::warn!(reason = %detail, "graph-storage dependency unavailable");
CanonicalError::service_unavailable()
.with_retry_after_seconds(5)
.create()
}
fn corrupt(reason: String) -> CanonicalError {
tracing::error!(reason = %reason, "graph-storage detected durable corruption");
GraphNodeError::data_loss(reason).with_resource("").create()
}
fn unexpected(error: &DomainError) -> CanonicalError {
tracing::error!(detail = %error, "unexpected graph-storage failure");
GraphNodeError::unknown("internal error").create()
}
fn operational_outcome(error: DomainError) -> CanonicalError {
match error {
DomainError::Unavailable { detail } => unavailable(&detail),
DomainError::Deadline => {
GraphNodeError::deadline_exceeded("operation exceeded its deadline").create()
}
DomainError::Cancelled => GraphNodeError::cancelled().create(),
DomainError::Corrupt { reason } => corrupt(reason),
ref other => unexpected(other),
}
}
impl From<DomainError> for CanonicalError {
fn from(error: DomainError) -> Self {
client_correctable(error)
.or_else(routing_outcome)
.unwrap_or_else(operational_outcome)
}
}
#[cfg(test)]
mod tests {
use graph_storage_sdk::models::{ItemError, ItemFamily};
use graph_storage_sdk::plugin_api::{GraphEngineError, GraphStoreError};
use super::{CanonicalError, DomainError};
fn status_of(error: DomainError) -> u16 {
CanonicalError::from(error).status_code()
}
#[test]
fn every_domain_failure_carries_the_status_its_category_fixes() {
let cases: Vec<(DomainError, u16)> = vec![
(
DomainError::Validation {
items: vec![ItemError {
index: 0,
family: ItemFamily::Node,
gts_type: None,
pointer: Some("/payload/severity".to_owned()),
message: "not one of the accepted values".to_owned(),
}],
},
400,
),
(DomainError::Validation { items: Vec::new() }, 400),
(DomainError::invalid("a message"), 400),
(
DomainError::limit_combination("a mode without its query"),
400,
),
(
DomainError::InvalidQuery {
message: "unknown field".to_owned(),
},
400,
),
(
DomainError::LimitExceeded {
what: "depth 9 is outside 1..=5".to_owned(),
},
400,
),
(
DomainError::CasConflict {
reason: "expected version 3".to_owned(),
},
409,
),
(DomainError::Serialization, 409),
(
DomainError::StaleGeneration {
recorded: 7,
offered: 6,
},
400,
),
(DomainError::IdempotencyMismatch, 409),
(DomainError::IdempotencyExpired, 400),
(DomainError::NotFound, 404),
(DomainError::AccessDenied, 404),
(
DomainError::SourceNamespaceForbidden {
namespace: "scm".to_owned(),
},
403,
),
(
DomainError::ScopeUnservable {
reason: "allow_all".to_owned(),
},
400,
),
(
DomainError::VectorSearchUnavailable {
reason: "epoch 2 != 1".to_owned(),
},
400,
),
(
DomainError::Unsupported {
what: "topology".to_owned(),
},
501,
),
(
DomainError::Unavailable {
detail: "pool exhausted".to_owned(),
},
503,
),
(DomainError::Deadline, 504),
(DomainError::Cancelled, 499),
(
DomainError::Corrupt {
reason: "dangling edge".to_owned(),
},
500,
),
(DomainError::internal("a bug"), 500),
];
for (error, expected) in cases {
let rendered = error.to_string();
assert_eq!(
status_of(error),
expected,
"`{rendered}` must answer {expected}"
);
}
}
#[test]
fn a_forbidden_namespace_says_so_rather_than_reading_as_absent() {
let error = CanonicalError::from(DomainError::SourceNamespaceForbidden {
namespace: "scm".to_owned(),
});
assert_eq!(error.status_code(), 403);
assert!(
format!("{error:?}").contains(super::reasons::SOURCE_NAMESPACE_FORBIDDEN),
"the stable reason travels in the machine-readable slot: {error:?}"
);
}
#[test]
fn every_item_of_a_failed_batch_is_reported_with_its_address() {
let error = CanonicalError::from(DomainError::Validation {
items: vec![
ItemError {
index: 0,
family: ItemFamily::Node,
gts_type: None,
pointer: Some("/payload/a".to_owned()),
message: "first".to_owned(),
},
ItemError {
index: 3,
family: ItemFamily::Edge,
gts_type: None,
pointer: None,
message: "second".to_owned(),
},
],
});
let rendered = format!("{error:?}");
assert!(rendered.contains("nodes[0]/payload/a"), "{rendered}");
assert!(rendered.contains("edges[3]"), "{rendered}");
assert!(rendered.contains("second"), "{rendered}");
}
#[test]
fn plugin_errors_classify_through_the_same_mapping() {
assert_eq!(status_of(GraphStoreError::NotFound.into()), 404);
assert_eq!(
status_of(
GraphStoreError::LimitExceeded {
what: "too many".to_owned()
}
.into()
),
400
);
assert_eq!(status_of(GraphStoreError::Serialization.into()), 409);
assert_eq!(
status_of(GraphStoreError::Unsupported { what: "snapshots" }.into()),
501
);
assert_eq!(status_of(GraphEngineError::Deadline.into()), 504);
assert_eq!(
status_of(
GraphEngineError::ScopeNotEnforceable {
reason: "tenant subtree".to_owned()
}
.into()
),
400
);
assert_eq!(
status_of(GraphEngineError::Internal("boom".to_owned()).into()),
500
);
}
}