feat(proto): typed errors (ErrorInfo/ErrorReason) via Status details #342

Merged
KofTwentyTwo merged 2 commits from story/ws-a1-typed-errors into dev 2026-07-31 19:12:45 +00:00
8 changed files with 475 additions and 29 deletions

No files matched your search

Generated
+1
View File
@@ -2594,6 +2594,7 @@ dependencies = [
name = "nuncio-proto"
version = "0.1.0"
dependencies = [
"bytes",
"prost",
"protoc-bin-vendored",
"thiserror 1.0.69",
+1
View File
@@ -13,6 +13,7 @@ workspace = true
tonic.workspace = true
tonic-prost.workspace = true
prost.workspace = true
bytes.workspace = true
thiserror.workspace = true
[build-dependencies]
Binary file not shown.
@@ -2,6 +2,64 @@ syntax = "proto3";
package nuncio.v1;
// ErrorReason is the stable, machine-readable taxonomy of failure conditions
// the daemon reports to clients. It is carried inside an `ErrorInfo` in a
// failed RPC's gRPC status-details trailer so a client can branch on a
// discriminant instead of string-matching human-readable (English) error
// text. Each reason maps to a fixed canonical gRPC status code (see the
// server-side helper that builds the `Status`), so the transport-level code
// and this application-level reason always agree.
//
// Values are append-only: never renumber or repurpose an existing value.
// New failure conditions get a new value; `ERROR_REASON_UNSPECIFIED` is the
// zero value and is treated as a generic internal error by clients that do
// not recognize a (newer) reason.
enum ErrorReason {
ERROR_REASON_UNSPECIFIED = 0;
// A request was syntactically or semantically invalid (missing/blank
// required field, out-of-range value, unparseable NSQL, etc.).
ERROR_REASON_VALIDATION_FAILED = 1;
// No account exists for the referenced id.
ERROR_REASON_ACCOUNT_NOT_FOUND = 2;
// No filter rule exists for the referenced id.
ERROR_REASON_RULE_NOT_FOUND = 3;
// No stored message exists for the referenced id.
ERROR_REASON_MESSAGE_NOT_FOUND = 4;
// No stored calendar event exists for the referenced id.
ERROR_REASON_EVENT_NOT_FOUND = 5;
// No stored contact exists for the referenced id.
ERROR_REASON_CONTACT_NOT_FOUND = 6;
// The resource being created already exists.
ERROR_REASON_ALREADY_EXISTS = 7;
// A precondition is unmet: the operation requires configuration that is
// not present yet (e.g. no CalDAV/CardDAV account configured to sync).
ERROR_REASON_NOT_CONFIGURED = 8;
// A required backend (mail/calendar/contacts server) could not be reached.
ERROR_REASON_BACKEND_UNREACHABLE = 9;
// The call was not authenticated (missing/invalid bearer credentials).
ERROR_REASON_AUTH_REQUIRED = 10;
// The requested operation is not supported (no honest implementation).
ERROR_REASON_UNSUPPORTED = 11;
// An unexpected internal error the client cannot act on specifically.
ERROR_REASON_INTERNAL = 12;
}
// ErrorInfo is the structured error payload the daemon attaches to a failed
// RPC's gRPC status-details (`grpc-status-details-bin`) trailer. It carries a
// stable `reason` discriminant, optional structured `metadata` (e.g. the id
// of the resource that was not found), and the same human-readable `message`
// that the enclosing `Status` carries. Clients decode this to branch on
// `reason` rather than parsing `message`.
message ErrorInfo {
// Stable machine-readable failure discriminant.
ErrorReason reason = 1;
// Optional structured context (e.g. {"account_id": "acct-1"}). Keys are
// stable identifiers; values are the concrete offending inputs.
map<string, string> metadata = 2;
// Human-readable message, identical to the enclosing `Status` message.
string message = 3;
}
// System service exposes daemon-level introspection RPCs.
//
// This is the foundation of the versioned gRPC API (see
+153
View File
@@ -0,0 +1,153 @@
//! Typed, machine-readable gRPC errors for the `nuncio.v1` contract.
//!
//! A failed RPC should carry a structured [`v1::ErrorInfo`] -- a stable
//! [`v1::ErrorReason`] discriminant, optional metadata, and a human message
//! -- in the gRPC `grpc-status-details-bin` status-details trailer, alongside
//! the canonical gRPC [`Code`] that the reason maps to. Servers build such a
//! `Status` with [`status`] / [`status_with_metadata`]; clients read the
//! reason back with [`error_reason`] / [`error_info`] and branch on it,
//! rather than string-matching the human-readable (English) message text.
//!
//! The reason-to-code mapping is centralized in [`ErrorReason::code`] so the
//! transport-level status code and the application-level reason can never
//! drift apart at a call site.
use crate::v1::{ErrorInfo, ErrorReason};
use bytes::Bytes;
use prost::Message;
use tonic::{Code, Status};
impl ErrorReason {
/// The canonical gRPC [`Code`] this reason maps to. Both the unspecified
/// zero value and an explicit internal error map to [`Code::Internal`],
/// so an older client that receives a reason it does not recognize (and
/// decodes it as `Unspecified`) still sees an internal-class failure.
#[must_use]
pub fn code(self) -> Code {
match self {
ErrorReason::Unspecified | ErrorReason::Internal => Code::Internal,
ErrorReason::ValidationFailed => Code::InvalidArgument,
ErrorReason::AccountNotFound
| ErrorReason::RuleNotFound
| ErrorReason::MessageNotFound
| ErrorReason::EventNotFound
| ErrorReason::ContactNotFound => Code::NotFound,
ErrorReason::AlreadyExists => Code::AlreadyExists,
ErrorReason::NotConfigured => Code::FailedPrecondition,
ErrorReason::BackendUnreachable => Code::Unavailable,
ErrorReason::AuthRequired => Code::Unauthenticated,
ErrorReason::Unsupported => Code::Unimplemented,
}
}
}
/// Builds a [`Status`] for `reason` carrying `message` both as the `Status`
/// message and inside a prost-encoded [`v1::ErrorInfo`] in the
/// `grpc-status-details-bin` trailer, with the canonical code from
/// [`ErrorReason::code`].
pub fn status(reason: ErrorReason, message: impl Into<String>) -> Status {
build(reason, message.into(), Vec::new())
}
/// Like [`status`], but also attaches structured `metadata` (e.g. the id of
/// the resource that was not found) to the encoded [`v1::ErrorInfo`].
pub fn status_with_metadata(
reason: ErrorReason,
message: impl Into<String>,
metadata: impl IntoIterator<Item = (String, String)>,
) -> Status {
build(reason, message.into(), metadata.into_iter().collect())
}
fn build(reason: ErrorReason, message: String, metadata: Vec<(String, String)>) -> Status {
let info = ErrorInfo {
reason: reason as i32,
metadata: metadata.into_iter().collect(),
message: message.clone(),
};
Status::with_details(reason.code(), message, Bytes::from(info.encode_to_vec()))
}
/// Decodes the [`v1::ErrorInfo`] carried in `status`'s details trailer, if
/// one is present and well-formed. Returns `None` when the status carries no
/// details (e.g. an error not built through this module) or the details are
/// not a valid `ErrorInfo`.
#[must_use]
pub fn error_info(status: &Status) -> Option<ErrorInfo> {
let details = status.details();
if details.is_empty() {
return None;
}
ErrorInfo::decode(details).ok()
}
/// Decodes just the [`v1::ErrorReason`] from `status`'s details trailer,
/// returning `None` when no typed [`v1::ErrorInfo`] is present.
#[must_use]
pub fn error_reason(status: &Status) -> Option<ErrorReason> {
error_info(status).map(|info| info.reason())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_reason_maps_to_its_canonical_code() {
assert_eq!(ErrorReason::Unspecified.code(), Code::Internal);
assert_eq!(ErrorReason::ValidationFailed.code(), Code::InvalidArgument);
assert_eq!(ErrorReason::AccountNotFound.code(), Code::NotFound);
assert_eq!(ErrorReason::RuleNotFound.code(), Code::NotFound);
assert_eq!(ErrorReason::MessageNotFound.code(), Code::NotFound);
assert_eq!(ErrorReason::EventNotFound.code(), Code::NotFound);
assert_eq!(ErrorReason::ContactNotFound.code(), Code::NotFound);
assert_eq!(ErrorReason::AlreadyExists.code(), Code::AlreadyExists);
assert_eq!(ErrorReason::NotConfigured.code(), Code::FailedPrecondition);
assert_eq!(ErrorReason::BackendUnreachable.code(), Code::Unavailable);
assert_eq!(ErrorReason::AuthRequired.code(), Code::Unauthenticated);
assert_eq!(ErrorReason::Unsupported.code(), Code::Unimplemented);
assert_eq!(ErrorReason::Internal.code(), Code::Internal);
}
#[test]
fn status_carries_reason_code_and_decodable_details() {
let status = status(ErrorReason::MessageNotFound, "message 'm-1' not found");
assert_eq!(status.code(), Code::NotFound);
assert_eq!(status.message(), "message 'm-1' not found");
let info = error_info(&status).expect("typed details present");
assert_eq!(info.reason(), ErrorReason::MessageNotFound);
assert_eq!(info.message, "message 'm-1' not found");
assert!(info.metadata.is_empty());
assert_eq!(
error_reason(&status),
Some(ErrorReason::MessageNotFound),
"the reason round-trips through the encoded details"
);
}
#[test]
fn status_with_metadata_round_trips_context() {
let status = status_with_metadata(
ErrorReason::AccountNotFound,
"account 'acct-1' not found",
[("account_id".to_string(), "acct-1".to_string())],
);
assert_eq!(status.code(), Code::NotFound);
let info = error_info(&status).expect("typed details present");
assert_eq!(info.reason(), ErrorReason::AccountNotFound);
assert_eq!(
info.metadata.get("account_id").map(String::as_str),
Some("acct-1")
);
}
#[test]
fn plain_status_without_details_decodes_to_none() {
let status = Status::not_found("no typed details here");
assert_eq!(error_info(&status), None);
assert_eq!(error_reason(&status), None);
}
}
+1
View File
@@ -22,6 +22,7 @@
pub mod addr;
pub mod client;
pub mod errors;
pub use addr::{grpc_addr_from_env, DEFAULT_GRPC_ADDR, GRPC_ADDR_ENV_VAR};
+90 -29
View File
@@ -10,6 +10,7 @@ use nuncio_contacts::ContactsBackend;
use nuncio_core::{CoreCommand, CoreEvent, EventBus};
use nuncio_filter::{FilterEngine, NsqlParser, NsqlValidator, ValidationOptions};
use nuncio_mail::{ImapEngine, MailBackend, MessageSender, SmtpTransportEngine};
use nuncio_proto::errors;
use nuncio_proto::v1::accounts_server::{Accounts, AccountsServer};
use nuncio_proto::v1::audit_server::{Audit, AuditServer};
use nuncio_proto::v1::calendar_server::{Calendar, CalendarServer};
@@ -19,6 +20,7 @@ use nuncio_proto::v1::export_server::{Export, ExportServer};
use nuncio_proto::v1::filters_server::{Filters, FiltersServer};
use nuncio_proto::v1::mail_server::{Mail, MailServer};
use nuncio_proto::v1::system_server::{System, SystemServer};
use nuncio_proto::v1::ErrorReason;
use nuncio_proto::v1::{
export_request, AccountConfig as AccountConfigProto, AccountProtocol as AccountProtocolProto,
AddAccountRequest, AddAccountResponse, Attachment as AttachmentProto,
@@ -549,7 +551,7 @@ impl Accounts for AccountsGrpcService {
let config = map_account_config_from_proto(proto_config)?;
config
.validate()
.map_err(|e| Status::invalid_argument(e.to_string()))?;
.map_err(|e| errors::status(ErrorReason::ValidationFailed, e.to_string()))?;
// Store the password credential in the OS keyring FIRST, keyed by
// this account's `keyring_secret_key` -- it is never written to
@@ -615,7 +617,7 @@ impl Accounts for AccountsGrpcService {
let config = map_account_config_from_proto(proto_config)?;
config
.validate()
.map_err(|e| Status::invalid_argument(e.to_string()))?;
.map_err(|e| errors::status(ErrorReason::ValidationFailed, e.to_string()))?;
// Updating a non-existent account is a client error, not a silent
// create: reject it so a typo'd id never conjures a new account row.
@@ -672,7 +674,13 @@ impl Accounts for AccountsGrpcService {
.get_account(&req.id)
.await
.map_err(|e| Status::internal(format!("failed to look up account: {e}")))?
.ok_or_else(|| Status::not_found(format!("account '{}' not found", req.id)))?;
.ok_or_else(|| {
errors::status_with_metadata(
ErrorReason::AccountNotFound,
format!("account '{}' not found", req.id),
[("account_id".to_string(), req.id.clone())],
)
})?;
self.db
.delete_account(&req.id)
@@ -708,7 +716,13 @@ impl Accounts for AccountsGrpcService {
.get_account(&req.id)
.await
.map_err(|e| Status::internal(format!("failed to look up account: {e}")))?
.ok_or_else(|| Status::not_found(format!("account '{}' not found", req.id)))?;
.ok_or_else(|| {
errors::status_with_metadata(
ErrorReason::AccountNotFound,
format!("account '{}' not found", req.id),
[("account_id".to_string(), req.id.clone())],
)
})?;
let password = self
.secrets
@@ -954,16 +968,19 @@ impl CalendarGrpcService {
.collect();
if caldav_accounts.is_empty() {
return Err(Status::failed_precondition(if req.account_id.is_empty() {
"no CalDAV account is configured; add one with protocol CALDAV and a \
collection_url before syncing"
.to_string()
} else {
format!(
"no CalDAV account with id '{}' is configured",
req.account_id
)
}));
return Err(errors::status(
ErrorReason::NotConfigured,
if req.account_id.is_empty() {
"no CalDAV account is configured; add one with protocol CALDAV and a \
collection_url before syncing"
.to_string()
} else {
format!(
"no CalDAV account with id '{}' is configured",
req.account_id
)
},
));
}
let mut total = 0usize;
@@ -1076,7 +1093,13 @@ impl Calendar for CalendarGrpcService {
.db
.get_calendar_event(&req.event_id)
.await
.map_err(|e| Status::not_found(format!("event '{}' not found: {e}", req.event_id)))?;
.map_err(|e| {
errors::status_with_metadata(
ErrorReason::EventNotFound,
format!("event '{}' not found: {e}", req.event_id),
[("event_id".to_string(), req.event_id.clone())],
)
})?;
Ok(Response::new(GetEventResponse {
event: Some(map_calendar_event_to_proto(event)),
@@ -1183,7 +1206,11 @@ impl Contacts for ContactsGrpcService {
}
let contact = self.db.get_contact(&req.contact_id).await.map_err(|e| {
Status::not_found(format!("contact '{}' not found: {e}", req.contact_id))
errors::status_with_metadata(
ErrorReason::ContactNotFound,
format!("contact '{}' not found: {e}", req.contact_id),
[("contact_id".to_string(), req.contact_id.clone())],
)
})?;
Ok(Response::new(GetContactResponse {
@@ -1358,7 +1385,11 @@ impl Mail for MailGrpcService {
}
let email = self.db.get_message(&req.message_id).await.map_err(|e| {
Status::not_found(format!("message '{}' not found: {e}", req.message_id))
errors::status_with_metadata(
ErrorReason::MessageNotFound,
format!("message '{}' not found: {e}", req.message_id),
[("message_id".to_string(), req.message_id.clone())],
)
})?;
Ok(Response::new(GetMessageResponse {
@@ -1387,7 +1418,11 @@ impl Mail for MailGrpcService {
.set_message_read(&req.message_id, req.read)
.await
.map_err(|e| {
Status::not_found(format!("message '{}' not found: {e}", req.message_id))
errors::status_with_metadata(
ErrorReason::MessageNotFound,
format!("message '{}' not found: {e}", req.message_id),
[("message_id".to_string(), req.message_id.clone())],
)
})?;
self.event_bus.process_command(CoreCommand::MarkRead {
@@ -1652,11 +1687,19 @@ impl Filters for FiltersGrpcService {
) -> Result<Response<CreateRuleResponse>, Status> {
let req = request.into_inner();
let rule = NsqlParser::parse_rule(req.name, req.priority, &req.nsql)
.map_err(|e| Status::invalid_argument(format!("NSQL syntax error: {e}")))?;
let rule = NsqlParser::parse_rule(req.name, req.priority, &req.nsql).map_err(|e| {
errors::status(
ErrorReason::ValidationFailed,
format!("NSQL syntax error: {e}"),
)
})?;
NsqlValidator::validate(&rule, &ValidationOptions::default())
.map_err(|e| Status::invalid_argument(format!("NSQL validation error: {e}")))?;
NsqlValidator::validate(&rule, &ValidationOptions::default()).map_err(|e| {
errors::status(
ErrorReason::ValidationFailed,
format!("NSQL validation error: {e}"),
)
})?;
self.db
.save_filter_rule(&rule)
@@ -1748,8 +1791,12 @@ impl Filters for FiltersGrpcService {
) -> Result<Response<PreviewRuleResponse>, Status> {
let req = request.into_inner();
let rule = NsqlParser::parse_rule("Preview Rule", 0, &req.nsql)
.map_err(|e| Status::invalid_argument(format!("NSQL syntax error: {e}")))?;
let rule = NsqlParser::parse_rule("Preview Rule", 0, &req.nsql).map_err(|e| {
errors::status(
ErrorReason::ValidationFailed,
format!("NSQL syntax error: {e}"),
)
})?;
let preview_engine = FilterEngine::new(vec![rule])
.map_err(|e| Status::internal(format!("failed to build preview engine: {e}")))?;
@@ -1788,17 +1835,31 @@ impl Filters for FiltersGrpcService {
.map_err(|e| Status::internal(format!("failed to list filter rules: {e}")))?
.into_iter()
.find(|r| r.id == req.id)
.ok_or_else(|| Status::not_found(format!("filter rule '{}' not found", req.id)))?;
.ok_or_else(|| {
errors::status_with_metadata(
ErrorReason::RuleNotFound,
format!("filter rule '{}' not found", req.id),
[("rule_id".to_string(), req.id.clone())],
)
})?;
let name = req.name.unwrap_or(existing.name);
let nsql = req.nsql.unwrap_or(existing.nsql_text);
let priority = req.priority.unwrap_or(existing.priority);
let mut rule = NsqlParser::parse_rule(name, priority, &nsql)
.map_err(|e| Status::invalid_argument(format!("NSQL syntax error: {e}")))?;
let mut rule = NsqlParser::parse_rule(name, priority, &nsql).map_err(|e| {
errors::status(
ErrorReason::ValidationFailed,
format!("NSQL syntax error: {e}"),
)
})?;
NsqlValidator::validate(&rule, &ValidationOptions::default())
.map_err(|e| Status::invalid_argument(format!("NSQL validation error: {e}")))?;
NsqlValidator::validate(&rule, &ValidationOptions::default()).map_err(|e| {
errors::status(
ErrorReason::ValidationFailed,
format!("NSQL validation error: {e}"),
)
})?;
rule.id = existing.id;
@@ -0,0 +1,171 @@
//! Offline end-to-end test that typed `ErrorInfo` details survive the gRPC
//! wire.
//!
//! Boots a real `nunciod` daemon gRPC server
//! (`nunciod::grpc::serve_on_listener_with_overrides`) on an ephemeral
//! loopback port, backed by a real temp file-backed [`DatabaseEngine`] and a
//! [`SecretManager::mock`] vault (never the real OS keyring), with every
//! engine override left at its default (no injected backends).
//!
//! Driving the authenticated `nuncio-proto` clients, it triggers several
//! known error conditions and proves a client can machine-read each failure:
//! the returned `tonic::Status` carries the correct canonical gRPC code AND a
//! prost-encoded [`nuncio_proto::v1::ErrorInfo`] in the
//! `grpc-status-details-bin` trailer whose `reason` (and metadata) decode
//! back exactly, so a client never has to string-match the English message.
//!
//! Fully offline: no real network, no real OS keyring, no live server.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use nuncio_core::EventBus;
use nuncio_filter::FilterEngine;
use nuncio_proto::errors;
use nuncio_proto::v1::{
CalendarSyncRequest, CreateRuleRequest, ErrorReason, GetMessageRequest, RemoveAccountRequest,
};
use nuncio_store::db::DatabaseEngine;
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
use nunciod::grpc::{
serve_on_listener_with_overrides, AccountsEngineOverrides, CalendarEngineOverrides,
ContactsEngineOverrides, MailEngineOverrides,
};
use std::sync::Arc;
use tokio::net::TcpListener;
use tonic::Code;
/// Boots the real daemon gRPC server on an ephemeral loopback port with every
/// override at its default. Returns the server address and minted bearer
/// token; the `TempDir` guard is returned so the caller controls its lifetime
/// for the whole test (the spawned server keeps using the db).
async fn boot_daemon() -> (std::net::SocketAddr, String, tempfile::TempDir) {
let secrets = Arc::new(SecretManager::mock());
let dir = tempfile::tempdir().expect("create temp dir for file-backed db");
let db_path = dir.path().join("typed_errors_e2e.db");
let db = Arc::new(
DatabaseEngine::connect_file(&db_path, &secrets)
.await
.expect("open temp file-backed database"),
);
let token = hex::encode(
secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.expect("mint gRPC bearer token from mock vault"),
);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
let event_bus = Arc::new(EventBus::new());
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let server_secrets = secrets.clone();
let server_token = token.clone();
tokio::spawn(async move {
let _ = serve_on_listener_with_overrides(
listener,
event_bus,
db,
filter_engine,
server_secrets,
server_token,
MailEngineOverrides::default(),
CalendarEngineOverrides::default(),
ContactsEngineOverrides::default(),
AccountsEngineOverrides::default(),
)
.await;
});
(addr, token, dir)
}
#[tokio::test]
async fn typed_error_reasons_decode_from_status_details_over_the_wire() {
let (addr, token, _db_dir) = boot_daemon().await;
let addr_str = addr.to_string();
// ---- account-not-found: NotFound + ACCOUNT_NOT_FOUND + account_id ----
let mut accounts = nuncio_proto::client::connect_accounts(&addr_str, &token)
.await
.expect("accounts client connects");
let status = accounts
.remove_account(RemoveAccountRequest {
id: "no-such-account".to_string(),
})
.await
.expect_err("removing a nonexistent account must fail");
assert_eq!(status.code(), Code::NotFound);
let info = errors::error_info(&status).expect("typed ErrorInfo present in details trailer");
assert_eq!(info.reason(), ErrorReason::AccountNotFound);
assert_eq!(
info.metadata.get("account_id").map(String::as_str),
Some("no-such-account"),
"the missing id is carried as machine-readable metadata"
);
assert_eq!(info.message, status.message());
assert_eq!(
errors::error_reason(&status),
Some(ErrorReason::AccountNotFound)
);
// ---- message-not-found: NotFound + MESSAGE_NOT_FOUND + message_id ----
let mut mail = nuncio_proto::client::connect_mail(&addr_str, &token)
.await
.expect("mail client connects");
let status = mail
.get_message(GetMessageRequest {
message_id: "no-such-message".to_string(),
})
.await
.expect_err("fetching a nonexistent message must fail");
assert_eq!(status.code(), Code::NotFound);
assert_eq!(
errors::error_reason(&status),
Some(ErrorReason::MessageNotFound)
);
let info = errors::error_info(&status).expect("typed ErrorInfo present");
assert_eq!(
info.metadata.get("message_id").map(String::as_str),
Some("no-such-message")
);
// ---- not-configured: FailedPrecondition + NOT_CONFIGURED ----
let mut calendar = nuncio_proto::client::connect_calendar(&addr_str, &token)
.await
.expect("calendar client connects");
let status = calendar
.sync(CalendarSyncRequest {
account_id: String::new(),
calendar_id: "personal".to_string(),
start_window: 0,
end_window: 0,
})
.await
.expect_err("syncing with no CalDAV account configured must fail");
assert_eq!(status.code(), Code::FailedPrecondition);
assert_eq!(
errors::error_reason(&status),
Some(ErrorReason::NotConfigured)
);
// ---- validation-failed: InvalidArgument + VALIDATION_FAILED ----
let mut filters = nuncio_proto::client::connect_filters(&addr_str, &token)
.await
.expect("filters client connects");
let status = filters
.create_rule(CreateRuleRequest {
name: "bad rule".to_string(),
nsql: "this is not valid nsql".to_string(),
priority: 0,
})
.await
.expect_err("creating a rule from unparseable NSQL must fail");
assert_eq!(status.code(), Code::InvalidArgument);
assert_eq!(
errors::error_reason(&status),
Some(ErrorReason::ValidationFailed)
);
}