use thiserror::Error;
use wyrd_spec::error::{WyrdError, WyrdProblem, WyrdStorageError};
use wyrd_spec::vala::error::BifrostError;
use wyrd_tonic::error::WYRD_ERROR_HEADER;
#[derive(Debug, Error)]
pub enum WyrdClientError {
#[error("config validation failed: {field}: {reason}")]
Config {
field: String,
reason: String,
},
#[error("transport unavailable ({transport}): {message}")]
TransportDown {
transport: String,
message: String,
},
#[error(
"no credentials available; set WYRD_ACCESS_TOKEN, WYRD_WORKLOAD_TOKEN+WYRD_TENANT, \
or WYRD_API_KEY, pass ClientConfig::credential, or add [default].api_key to \
~/.config/wyrd/credentials.toml"
)]
NoCredentials,
#[error("saved user login cannot be used ({reason}): {message}")]
SavedLogin {
reason: &'static str,
message: String,
},
}
impl WyrdClientError {
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::Config { .. } => "WYRD_CLIENT_400_CONFIG_INVALID",
Self::NoCredentials => "WYRD_CLIENT_401_NO_CREDENTIALS",
Self::TransportDown { .. } => "WYRD_CLIENT_503_TRANSPORT_DOWN",
Self::SavedLogin { .. } => "WYRD_CLIENT_401_SAVED_LOGIN_UNUSABLE",
}
}
}
impl From<&WyrdClientError> for WyrdError {
fn from(error: &WyrdClientError) -> Self {
let message = error.to_string();
match error {
WyrdClientError::Config { field, reason } => Self::ClientConfigInvalid {
message,
details: serde_json::json!({ "field": field, "reason": reason }),
},
WyrdClientError::NoCredentials => Self::ClientNoCredentials {
message,
details: serde_json::json!({}),
},
WyrdClientError::TransportDown { transport, .. } => Self::ClientTransportDown {
message,
details: serde_json::json!({ "transport": transport }),
},
WyrdClientError::SavedLogin { reason, .. } => Self::ClientSavedLoginUnusable {
message,
details: serde_json::json!({ "reason": reason }),
},
}
}
}
impl From<WyrdClientError> for WyrdError {
fn from(error: WyrdClientError) -> Self {
Self::from(&error)
}
}
pub fn from_problem_json(body: &serde_json::Value) -> WyrdError {
let code = body["code"].as_str().unwrap_or("");
let message = body["detail"].as_str().unwrap_or("").to_owned();
let details = body
.get("details")
.cloned()
.unwrap_or(serde_json::json!({}));
code_to_wyrd_error(code, message, details)
}
pub fn from_problem(problem: &WyrdProblem) -> WyrdError {
code_to_wyrd_error(
&problem.code,
problem.detail.clone(),
problem.details.clone(),
)
}
pub fn from_grpc_status(status: &wyrd_tonic::tonic::Status) -> WyrdError {
use wyrd_tonic::tonic_types::StatusExt as _;
if let Some(problem) = status
.metadata()
.get_bin(WYRD_ERROR_HEADER)
.and_then(|value| value.to_bytes().ok())
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
{
return from_problem_json(&problem);
}
if let Some(info) = status.get_details_error_info() {
let details = if info.metadata.is_empty() {
serde_json::json!({})
} else {
serde_json::Value::Object(
info.metadata
.into_iter()
.map(|(k, v)| (k, serde_json::Value::String(v)))
.collect(),
)
};
return code_to_wyrd_error(&info.reason, status.message().to_owned(), details);
}
WyrdError::Internal {
message: status.message().to_owned(),
details: serde_json::json!({}),
}
}
pub(crate) fn code_to_wyrd_error(
code: &str,
message: String,
details: serde_json::Value,
) -> WyrdError {
if code.starts_with("WYRD_STORAGE_")
&& let Ok(error) = serde_json::from_value::<WyrdStorageError>(details.clone())
{
return error.into();
}
if code.starts_with("WYRD_VALA_")
&& let Ok(error) = serde_json::from_value::<BifrostError>(details.clone())
&& error.code() == code
{
return WyrdError::Vala { error };
}
if let Some(error) = BifrostError::from_unit_code(code) {
return WyrdError::Vala { error };
}
if let Some(reconstructed) = WyrdError::from_code(code, message.clone(), details.clone()) {
return reconstructed;
}
WyrdError::UpstreamFailure {
message: format!("server error: {message}"),
details: serde_json::json!({
"original_code": code,
"original_details": details,
}),
}
}
#[cfg(test)]
mod tests {
use super::{BifrostError, WyrdClientError, code_to_wyrd_error, from_problem_json};
use wyrd_spec::error::{WyrdError, WyrdStorageError};
#[test]
fn fieldless_bifrost_code_reconstructs_without_details() {
let received = code_to_wyrd_error(
"WYRD_VALA_403_BIFROST_CARD_SCOPE",
"Wyrd gateway answered HTTP 403".to_owned(),
serde_json::json!({}),
);
assert_eq!(received.code(), "WYRD_VALA_403_BIFROST_CARD_SCOPE");
assert_eq!(received.status(), 403);
}
#[test]
fn known_code_reconstructs_correct_variant() {
let body = serde_json::json!({
"code": "WYRD_SPEC_404_NOT_FOUND",
"detail": "card not found",
"details": {},
});
let err = from_problem_json(&body);
assert_eq!(err.code(), "WYRD_SPEC_404_NOT_FOUND");
}
#[test]
fn bifrost_problem_details_reconstruct_exact_variant() {
let sent = WyrdError::Vala {
error: BifrostError::VariantInvalidJson {
field: "parse_json".to_owned(),
row: 0,
path: String::new(),
},
};
let received = from_problem_json(&sent.as_problem_json());
assert_eq!(received.code(), "WYRD_VALA_400_VARIANT_INVALID_JSON");
assert_eq!(received.as_problem_json(), sent.as_problem_json());
}
#[test]
fn unknown_code_preserved_in_catch_all() {
let body = serde_json::json!({
"code": "WYRD_CUSTOM_999_EXPERIMENTAL",
"detail": "experimental error",
"details": {},
});
let err = from_problem_json(&body);
assert_eq!(
err.code(),
"WYRD_SPEC_502_UPSTREAM_FAILURE",
"unknown code must fall through to UpstreamFailure catch-all"
);
let json = err.as_problem_json();
assert_eq!(
json["details"]["original_code"].as_str(),
Some("WYRD_CUSTOM_999_EXPERIMENTAL"),
"original code must be preserved in catch-all variant details"
);
}
#[test]
fn bifrost_problems_reconstruct_their_exact_variant() {
let table = || "vala.datasets.events".to_owned();
for (sent, status) in [
(BifrostError::TableNotFound { table: table() }, 404),
(BifrostError::FingerprintMismatch { table: table() }, 409),
(
BifrostError::CompactionTargetMismatch { table: table() },
409,
),
(BifrostError::CompactionTypeMismatch { table: table() }, 409),
(
BifrostError::SchemaParse {
detail: "too many leaves".to_owned(),
},
400,
),
(
BifrostError::InvalidCompactionTarget {
table: table(),
bytes: 1,
},
400,
),
(
BifrostError::QueryInvalidSql {
detail: "parser rejected SELECT FROM".to_owned(),
},
400,
),
(BifrostError::QueryAdmissionRejected, 429),
(BifrostError::QueryQueueFull, 429),
(BifrostError::QueryMemoryRequestTooLarge, 422),
(BifrostError::OracleRoleUnavailable, 503),
(BifrostError::RunningQueryNotFound, 404),
(BifrostError::RunningQueryConflict, 409),
(BifrostError::RunningQueryControlUnavailable, 503),
] {
let sent = WyrdError::from(sent);
let received = from_problem_json(&sent.as_problem_json());
assert_eq!(received.status(), status, "{}", sent.code());
assert_eq!(received.as_problem_json(), sent.as_problem_json());
}
}
#[test]
fn previously_collapsed_code_now_keeps_its_status() {
let body = serde_json::json!({
"code": "WYRD_PERMISSION_403_DENIED_RBAC",
"detail": "permission denied",
"details": {},
});
let err = from_problem_json(&body);
assert_eq!(err.code(), "WYRD_PERMISSION_403_DENIED_RBAC");
assert_eq!(
err.status(),
403,
"status must survive the round-trip, not collapse to 502"
);
}
#[test]
fn storage_problem_json_reconstructs_delegated_error() {
let source: WyrdError = WyrdStorageError::InvalidUploadId {
reason: "upload id must start with wyu_".to_owned(),
}
.into();
let mapped = from_problem_json(&source.as_problem_json());
assert_eq!(mapped.code(), "WYRD_STORAGE_400_INVALID_UPLOAD_ID");
assert_eq!(mapped.status(), 400);
assert_eq!(mapped.as_problem_json(), source.as_problem_json());
}
#[test]
fn client_error_code_accessor_reports_stable_codes() {
assert_eq!(
WyrdClientError::Config {
field: "f".to_owned(),
reason: "r".to_owned(),
}
.code(),
"WYRD_CLIENT_400_CONFIG_INVALID"
);
assert_eq!(
WyrdClientError::NoCredentials.code(),
"WYRD_CLIENT_401_NO_CREDENTIALS"
);
assert_eq!(
WyrdClientError::TransportDown {
transport: "http".to_owned(),
message: "refused".to_owned(),
}
.code(),
"WYRD_CLIENT_503_TRANSPORT_DOWN"
);
}
#[test]
fn no_credentials_guidance_names_the_credential_field() {
let message = WyrdClientError::NoCredentials.to_string();
assert!(
message.contains("ClientConfig::credential"),
"guidance must name the field a caller can actually set: {message}"
);
assert!(
!message.contains("ClientConfig::api_key"),
"guidance must not name the removed per-grant field: {message}"
);
}
#[test]
fn client_error_has_only_expected_variants() {
let _c = WyrdClientError::Config {
field: "f".to_owned(),
reason: "r".to_owned(),
};
let _d = WyrdClientError::TransportDown {
transport: "grpc".to_owned(),
message: "refused".to_owned(),
};
let _ = WyrdClientError::NoCredentials;
}
mod grpc_convergence {
use std::collections::HashMap;
use wyrd_tonic::tonic::Code;
use wyrd_tonic::tonic_types::{ErrorDetails, StatusExt};
use wyrd_spec::error::WyrdError;
use wyrd_spec::vala::error::BifrostError;
use wyrd_tonic::error::wyrd_error_to_status;
use crate::error::{from_grpc_status, from_problem_json};
#[test]
fn capacity_http_and_grpc_reconstruct_retryability_identically() {
for (sent, status) in [
(BifrostError::QueryAdmissionRejected, 429),
(BifrostError::QueryQueueFull, 429),
(BifrostError::QueryMemoryRequestTooLarge, 422),
(BifrostError::QueryExecutionFailed, 500),
] {
let sent = WyrdError::from(sent);
let from_http = from_problem_json(&sent.as_problem_json());
let from_grpc = from_grpc_status(&wyrd_error_to_status(sent.clone(), None));
assert_eq!(from_grpc.code(), sent.code());
assert_eq!(from_grpc.status(), status);
assert_eq!(from_grpc.as_problem_json(), from_http.as_problem_json());
}
}
#[test]
fn grpc_and_http_same_code_produce_equal_wyrd_error() {
let details = ErrorDetails::with_error_info(
"WYRD_SPEC_404_NOT_FOUND",
"wyrd.dev",
HashMap::new(),
);
let status = wyrd_tonic::tonic::Status::with_error_details(
Code::NotFound,
"card not found",
details,
);
let body = serde_json::json!({
"code": "WYRD_SPEC_404_NOT_FOUND",
"detail": "card not found",
"details": {},
});
let from_grpc = from_grpc_status(&status);
let from_http = from_problem_json(&body);
assert_eq!(
from_grpc.as_problem_json(),
from_http.as_problem_json(),
"gRPC and HTTP mappers must produce identical WyrdError for the same stable code"
);
}
#[test]
fn payload_limit_reconstructs_identically_across_transports() {
let sent = WyrdError::from(BifrostError::PayloadTooLarge {
bytes: 41_943_040,
limit: 8_388_608,
});
let detail = sent.to_string();
let grpc = wyrd_error_to_status(sent.clone(), None);
let http = sent.as_problem_json();
let from_grpc = from_grpc_status(&grpc);
let from_http = from_problem_json(&http);
assert_eq!(
from_grpc.as_problem_json(),
from_http.as_problem_json(),
"both transports must reconstruct the same payload-limit error"
);
for reconstructed in [&from_grpc, &from_http] {
assert_eq!(reconstructed.code(), "WYRD_VALA_413_PAYLOAD_TOO_LARGE");
assert_eq!(reconstructed.status(), 413);
assert_eq!(reconstructed.to_string(), detail);
let problem = reconstructed.as_problem_json();
assert_eq!(
problem["details"]["data"]["bytes"], 41_943_040,
"measured bytes must survive the transport: {problem}"
);
assert_eq!(
problem["details"]["data"]["limit"], 8_388_608,
"the enforced limit must survive the transport: {problem}"
);
let remediation = problem["remediation"].as_str().unwrap_or_default();
assert!(
!remediation.contains("32 MiB"),
"remediation must not contradict the supplied limit: {problem}"
);
assert!(
remediation.contains("limit"),
"remediation must point at the supplied limit: {problem}"
);
}
}
#[test]
fn grpc_unknown_code_preserved_in_catch_all() {
let details = ErrorDetails::with_error_info(
"WYRD_CUSTOM_999_UNKNOWN",
"wyrd.dev",
HashMap::new(),
);
let status = wyrd_tonic::tonic::Status::with_error_details(
Code::Unknown,
"unknown server error",
details,
);
let err = from_grpc_status(&status);
assert_eq!(
err.code(),
"WYRD_SPEC_502_UPSTREAM_FAILURE",
"unknown gRPC code must fall through to UpstreamFailure catch-all"
);
let json = err.as_problem_json();
assert_eq!(
json["details"]["original_code"].as_str(),
Some("WYRD_CUSTOM_999_UNKNOWN"),
"original code must be preserved in catch-all variant details"
);
}
}
}