use thiserror::Error;
const BACKEND_MESSAGE_CAP: usize = 300;
#[derive(Debug, Error)]
pub enum GraphError {
#[error(
"graph connection '{name}' is not registered (known connections: {known}); \
declare a `type: graph` data source with that name"
)]
ConnectionNotFound { name: String, known: String },
#[error("graph source '{name}': {reason}")]
InvalidConfig { name: String, reason: String },
#[error("graph view '{view}' failed validation: {source}")]
ViewValidationFailed {
view: String,
source: Box<GraphError>,
},
#[error(
"cypher_query is read-only: the keyword '{keyword}' at byte {offset} is not \
allowed (writes and procedure calls are rejected; the backend's READ ONLY \
transaction enforces this regardless)"
)]
MutationRejected {
keyword: &'static str,
offset: usize,
},
#[error("cypher_query 'columns': {reason}; accepted types: {accepted}")]
InvalidColumns {
reason: String,
accepted: &'static str,
},
#[error("cypher_query 'params' must be a JSON object, got {found}")]
InvalidParams { found: String },
#[error(
"graph column '{column}' at result row {row}: declared '{expected}' but the \
backend returned {found}; declare the column as 'json' (verbatim) or \
normalize in Cypher (toString()/toInteger())"
)]
TypeMismatch {
column: String,
row: usize,
expected: &'static str,
found: &'static str,
},
#[error(
"degraded recovery did not complete within {seconds}s (every per-view probe is \
individually bounded; this is the whole-recovery backstop)"
)]
RecoveryDeadlineExceeded { seconds: u64 },
#[error(
"graph column '{column}' at result row {row}: declared nullable: false but the \
backend returned null; relax the declaration to nullable: true or filter \
nulls in the view's Cypher (e.g. WHERE ... IS NOT NULL)"
)]
NotNullViolation { column: String, row: usize },
#[error(
"graph scan exceeded max_rows = {max_rows}: the backend returned more rows \
than the source allows per query; add a Cypher LIMIT or raise the \
source's max_rows"
)]
RowCapExceeded { max_rows: usize },
#[error(
"graph query timed out after {seconds}s (the source's query_timeout_seconds); \
narrow the traversal or raise the timeout"
)]
Timeout { seconds: u64 },
#[error(
"the graph backend stopped answering: no response within {seconds}s (the client-side bound; the server never reported a statement timeout — the backend or the network path to it is gone)"
)]
BackendSilent { seconds: u64 },
#[error(
"could not acquire a connection to the graph backend within {seconds}s: the \
backend may be unreachable, or every pooled connection is checked out (the \
query never started)"
)]
ConnectionAcquireTimeout { seconds: u64 },
#[error("graph backend '{source_name}' is unreachable: {reason}")]
Unavailable { source_name: String, reason: String },
#[error("graph backend error on '{source_name}' [{code}]: {message}")]
Backend {
source_name: String,
code: String,
message: String,
},
#[error(
"graph result row {row} carries {found} columns but {expected} were declared — \
the backend and the declared schema disagree"
)]
RowArityMismatch {
row: usize,
expected: usize,
found: usize,
},
#[error(
"graph response cell at row {row}, column {column} is not parseable agtype \
({reason})"
)]
MalformedCell {
row: usize,
column: usize,
reason: String,
},
}
impl GraphError {
pub fn backend(source_name: &str, code: &str, message: &str) -> Self {
let mut end = message.len().min(BACKEND_MESSAGE_CAP);
while end < message.len() && !message.is_char_boundary(end) {
end += 1;
}
let mut snippet = message[..end].to_string();
if end < message.len() {
snippet.push('…');
}
GraphError::Backend {
source_name: source_name.to_string(),
code: code.to_string(),
message: snippet,
}
}
}
pub use crate::util::json::json_kind;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backend_messages_are_bounded_on_char_boundaries() {
let long = "颱".repeat(200); let err = GraphError::backend("kg", "XX000", &long);
let GraphError::Backend { message, .. } = &err else {
panic!("backend variant");
};
assert!(message.len() <= BACKEND_MESSAGE_CAP + '…'.len_utf8() + 3);
assert!(message.ends_with('…'), "truncation is visible");
assert!(err.to_string().contains("[XX000]"));
}
#[test]
fn short_backend_messages_pass_untruncated_and_kinds_cover_json() {
let err = GraphError::backend("kg", "42601", "syntax error");
assert!(err.to_string().contains("syntax error"), "{err}");
assert!(!err.to_string().contains('…'), "no ellipsis when unclipped");
for (v, kind) in [
(serde_json::json!(null), "null"),
(serde_json::json!(true), "a boolean"),
(serde_json::json!(1), "a number"),
(serde_json::json!("s"), "a string"),
(serde_json::json!([1]), "an array"),
(serde_json::json!({}), "an object"),
] {
assert_eq!(json_kind(&v), kind);
}
}
#[test]
fn mutation_rejected_never_carries_query_text() {
let err = GraphError::MutationRejected {
keyword: "CREATE",
offset: 42,
};
let msg = err.to_string();
assert!(msg.contains("'CREATE'"));
assert!(msg.contains("byte 42"));
}
}