use axum::{http::StatusCode, Json};
use serde_json::{json, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiErrorCode {
Generic,
Unauthenticated,
Forbidden,
NamespaceNotFound,
InsufficientScope,
MemoryNotFound,
ConflictNotFound,
SkillNotFound,
SessionNotFound,
EntityNotFound,
InvalidRequest,
InvalidQueryParameter,
InvalidCursor,
InvalidMinSeq,
InvalidBody,
OpIdCollision,
UnexpectedLogIndex,
ReplicaBehind,
VersionUpgrade,
RateLimited,
InternalError,
NotLeader,
EngineUnavailable,
ClusterUnavailable,
LeaderUnavailable,
CommitTimeout,
}
impl ApiErrorCode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Generic => "generic",
Self::Unauthenticated => "unauthenticated",
Self::Forbidden => "forbidden",
Self::NamespaceNotFound => "namespace_not_found",
Self::InsufficientScope => "insufficient_scope",
Self::MemoryNotFound => "memory_not_found",
Self::ConflictNotFound => "conflict_not_found",
Self::SkillNotFound => "skill_not_found",
Self::SessionNotFound => "session_not_found",
Self::EntityNotFound => "entity_not_found",
Self::InvalidRequest => "invalid_request",
Self::InvalidQueryParameter => "invalid_query_parameter",
Self::InvalidCursor => "invalid_cursor",
Self::InvalidMinSeq => "invalid_min_seq",
Self::InvalidBody => "invalid_body",
Self::OpIdCollision => "op_id_collision",
Self::UnexpectedLogIndex => "unexpected_log_index",
Self::ReplicaBehind => "replica_behind",
Self::VersionUpgrade => "version_upgrade",
Self::RateLimited => "rate_limited",
Self::InternalError => "internal_error",
Self::NotLeader => "not_leader",
Self::EngineUnavailable => "engine_unavailable",
Self::ClusterUnavailable => "cluster_unavailable",
Self::LeaderUnavailable => "leader_unavailable",
Self::CommitTimeout => "commit_timeout",
}
}
}
pub fn api_error(
status: StatusCode,
code: ApiErrorCode,
message: impl Into<String>,
) -> (StatusCode, Json<Value>) {
(
status,
Json(json!({
"error": {
"code": code.as_str(),
"message": message.into(),
}
})),
)
}
pub fn api_error_with_hint(
status: StatusCode,
code: ApiErrorCode,
message: impl Into<String>,
hint: impl Into<String>,
) -> (StatusCode, Json<Value>) {
(
status,
Json(json!({
"error": {
"code": code.as_str(),
"message": message.into(),
"hint": hint.into(),
}
})),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_code_has_snake_case_wire_string() {
let codes = [
ApiErrorCode::Generic,
ApiErrorCode::Unauthenticated,
ApiErrorCode::Forbidden,
ApiErrorCode::NamespaceNotFound,
ApiErrorCode::InsufficientScope,
ApiErrorCode::MemoryNotFound,
ApiErrorCode::ConflictNotFound,
ApiErrorCode::SkillNotFound,
ApiErrorCode::SessionNotFound,
ApiErrorCode::EntityNotFound,
ApiErrorCode::InvalidRequest,
ApiErrorCode::InvalidQueryParameter,
ApiErrorCode::InvalidCursor,
ApiErrorCode::InvalidMinSeq,
ApiErrorCode::InvalidBody,
ApiErrorCode::OpIdCollision,
ApiErrorCode::UnexpectedLogIndex,
ApiErrorCode::ReplicaBehind,
ApiErrorCode::VersionUpgrade,
ApiErrorCode::RateLimited,
ApiErrorCode::InternalError,
ApiErrorCode::NotLeader,
ApiErrorCode::EngineUnavailable,
ApiErrorCode::ClusterUnavailable,
ApiErrorCode::LeaderUnavailable,
ApiErrorCode::CommitTimeout,
];
for code in codes {
let s = code.as_str();
assert!(!s.is_empty(), "code {code:?} has empty wire string");
assert!(
s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
"code {code:?} wire string {s:?} not snake_case"
);
assert!(
!s.starts_with('_') && !s.ends_with('_'),
"code {code:?} wire string {s:?} has leading/trailing underscore"
);
}
}
#[test]
fn all_wire_strings_are_unique() {
let codes = [
ApiErrorCode::Generic,
ApiErrorCode::Unauthenticated,
ApiErrorCode::Forbidden,
ApiErrorCode::NamespaceNotFound,
ApiErrorCode::InsufficientScope,
ApiErrorCode::MemoryNotFound,
ApiErrorCode::ConflictNotFound,
ApiErrorCode::SkillNotFound,
ApiErrorCode::SessionNotFound,
ApiErrorCode::EntityNotFound,
ApiErrorCode::InvalidRequest,
ApiErrorCode::InvalidQueryParameter,
ApiErrorCode::InvalidCursor,
ApiErrorCode::InvalidMinSeq,
ApiErrorCode::InvalidBody,
ApiErrorCode::OpIdCollision,
ApiErrorCode::UnexpectedLogIndex,
ApiErrorCode::ReplicaBehind,
ApiErrorCode::VersionUpgrade,
ApiErrorCode::RateLimited,
ApiErrorCode::InternalError,
ApiErrorCode::NotLeader,
ApiErrorCode::EngineUnavailable,
ApiErrorCode::ClusterUnavailable,
ApiErrorCode::LeaderUnavailable,
ApiErrorCode::CommitTimeout,
];
let mut seen = std::collections::HashSet::new();
for code in codes {
let s = code.as_str();
assert!(
seen.insert(s),
"duplicate wire string {s:?} for code {code:?}"
);
}
}
#[test]
fn api_error_emits_canonical_envelope() {
let (status, body) = api_error(
StatusCode::NOT_FOUND,
ApiErrorCode::MemoryNotFound,
"memory abc123 not found",
);
assert_eq!(status, StatusCode::NOT_FOUND);
let body = body.0;
assert_eq!(body["error"]["code"], "memory_not_found");
assert_eq!(body["error"]["message"], "memory abc123 not found");
assert!(body["error"].get("hint").is_none());
}
#[test]
fn api_error_with_hint_includes_hint() {
let (status, body) = api_error_with_hint(
StatusCode::PRECONDITION_FAILED,
ApiErrorCode::ReplicaBehind,
"replica has not applied seq 12345",
"retry the request or route to the leader",
);
assert_eq!(status, StatusCode::PRECONDITION_FAILED);
let body = body.0;
assert_eq!(body["error"]["code"], "replica_behind");
assert_eq!(
body["error"]["hint"],
"retry the request or route to the leader"
);
}
}