use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use orion_api::{ErrorBody, ErrorEnvelope, codes};
pub use orion_api::FieldError;
#[derive(Debug, thiserror::Error)]
pub enum OrionError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Validation failed: {message}")]
Validation {
code: &'static str,
message: String,
details: Vec<FieldError>,
},
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Unauthorized: {message}")]
UnauthorizedToken {
message: String,
wire_description: Option<&'static str>,
},
#[error("Forbidden: {0}")]
Forbidden(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("{context}")]
Internal {
context: String,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
#[error("Configuration error: {message}")]
Config { message: String },
#[error("Rate limited: {0}")]
RateLimited(String),
#[error("Rate limit key unavailable: {0}")]
RateLimitKeyUnavailable(String),
#[error("Payload too large: {0}")]
PayloadTooLarge(String),
#[error("Response too large: {0}")]
ResponseTooLarge(String),
#[error("Service unavailable: {message}")]
ServiceUnavailable {
reason: Unavailable,
message: String,
},
#[error("Timeout: channel '{channel}' exceeded {timeout_ms}ms")]
Timeout { channel: String, timeout_ms: u64 },
#[error("Unsupported media type: {0}")]
UnsupportedMediaType(String),
#[error("Method not allowed: {0}")]
MethodNotAllowed(String),
#[error("Storage error: {0}")]
Storage(#[from] sqlx::Error),
#[error("Engine error: {0}")]
Engine(#[from] dataflow_rs::DataflowError),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Unavailable {
ChannelQuarantined,
GuardBackend,
AtCapacity,
QueueClosed,
}
impl Unavailable {
pub fn is_transient(self) -> bool {
matches!(self, Self::GuardBackend | Self::AtCapacity)
}
pub fn retry_after_secs(self) -> Option<u32> {
match self {
Self::AtCapacity => Some(1),
Self::GuardBackend => Some(5),
Self::ChannelQuarantined | Self::QueueClosed => None,
}
}
}
impl OrionError {
pub fn is_retryable(&self) -> bool {
match self {
OrionError::Storage(_) => true,
OrionError::Engine(e) => e.retryable(),
OrionError::RateLimited(_) => true,
OrionError::ServiceUnavailable { reason, .. } => reason.is_transient(),
OrionError::Timeout { .. } => true,
_ => false,
}
}
pub fn unavailable(reason: Unavailable, message: impl Into<String>) -> Self {
OrionError::ServiceUnavailable {
reason,
message: message.into(),
}
}
pub fn internal(context: impl Into<String>) -> Self {
OrionError::Internal {
context: context.into(),
source: None,
}
}
pub fn internal_from(
context: impl Into<String>,
source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
) -> Self {
OrionError::Internal {
context: context.into(),
source: Some(source.into()),
}
}
pub fn validation(message: impl Into<String>) -> Self {
OrionError::Validation {
code: codes::VALIDATION_ERROR,
message: message.into(),
details: Vec::new(),
}
}
pub fn invalid_field(
path: impl Into<String>,
code: &'static str,
message: impl Into<String>,
) -> Self {
let message = message.into();
OrionError::Validation {
code: codes::VALIDATION_ERROR,
message: message.clone(),
details: vec![FieldError::new(path, code, message)],
}
}
}
impl OrionError {
pub fn response_parts(&self) -> (StatusCode, &'static str, String) {
match self {
OrionError::NotFound(msg) => (StatusCode::NOT_FOUND, codes::NOT_FOUND, msg.clone()),
OrionError::Validation { code, message, .. } => {
(StatusCode::BAD_REQUEST, code, message.clone())
}
OrionError::Unauthorized(msg) => {
(StatusCode::UNAUTHORIZED, codes::UNAUTHORIZED, msg.clone())
}
OrionError::UnauthorizedToken { message, .. } => (
StatusCode::UNAUTHORIZED,
codes::UNAUTHORIZED,
message.clone(),
),
OrionError::Forbidden(msg) => (StatusCode::FORBIDDEN, codes::FORBIDDEN, msg.clone()),
OrionError::Conflict(msg) => (StatusCode::CONFLICT, codes::CONFLICT, msg.clone()),
OrionError::UnsupportedMediaType(msg) => (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
codes::UNSUPPORTED_MEDIA_TYPE,
msg.clone(),
),
OrionError::MethodNotAllowed(msg) => (
StatusCode::METHOD_NOT_ALLOWED,
codes::METHOD_NOT_ALLOWED,
msg.clone(),
),
OrionError::ServiceUnavailable { message, .. } => (
StatusCode::SERVICE_UNAVAILABLE,
codes::SERVICE_UNAVAILABLE,
message.clone(),
),
OrionError::RateLimited(msg) | OrionError::RateLimitKeyUnavailable(msg) => (
StatusCode::TOO_MANY_REQUESTS,
codes::RATE_LIMITED,
msg.clone(),
),
OrionError::Timeout {
channel,
timeout_ms,
} => (
StatusCode::GATEWAY_TIMEOUT,
codes::TIMEOUT,
format!(
"Workflow execution on channel '{channel}' exceeded {timeout_ms}ms timeout"
),
),
OrionError::PayloadTooLarge(msg) => (
StatusCode::PAYLOAD_TOO_LARGE,
codes::PAYLOAD_TOO_LARGE,
msg.clone(),
),
OrionError::ResponseTooLarge(msg) => (
StatusCode::INTERNAL_SERVER_ERROR,
codes::RESPONSE_TOO_LARGE,
msg.clone(),
),
OrionError::Internal { .. } => (
StatusCode::INTERNAL_SERVER_ERROR,
codes::INTERNAL_ERROR,
"An internal error occurred".to_string(),
),
OrionError::Config { .. } => (
StatusCode::INTERNAL_SERVER_ERROR,
codes::CONFIG_ERROR,
"A configuration error occurred".to_string(),
),
OrionError::Storage(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
codes::STORAGE_ERROR,
"An internal storage error occurred".to_string(),
),
OrionError::Engine(e) => engine_error_response(e),
OrionError::Serialization(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
codes::SERIALIZATION_ERROR,
"An internal serialization error occurred".to_string(),
),
}
}
pub fn client_message(&self) -> String {
self.response_parts().2
}
fn log_internal_detail(&self) {
match self {
OrionError::Internal {
context,
source: None,
} => {
tracing::error!(error.category = "internal", error = %context, "internal error")
}
OrionError::Config { message } => {
tracing::error!(error.category = "config", error = %message, "config error")
}
OrionError::Storage(e) => {
tracing::error!(error.category = "storage", error = %e, "storage error")
}
OrionError::Serialization(e) => {
tracing::error!(error.category = "serialization", error = %e, "serialization error")
}
OrionError::ResponseTooLarge(detail) => {
tracing::error!(error.category = "response_too_large", error = %detail, "response exceeded a configured size cap")
}
OrionError::Internal {
context,
source: Some(source),
} => tracing::error!(
error.category = "internal",
error.context = %context,
error.source = %source,
"Internal error"
),
OrionError::Engine(e) => {
tracing::error!(error.category = "engine", error = %e, "Engine error")
}
_ => {}
}
}
}
impl OrionError {
pub fn field_errors(&self) -> &[FieldError] {
match self {
OrionError::Validation { details, .. } => details,
_ => &[],
}
}
}
impl IntoResponse for OrionError {
fn into_response(self) -> Response {
let details = match &self {
OrionError::Validation { details, .. } => details.clone(),
_ => Vec::new(),
};
let bearer_challenge = match &self {
OrionError::UnauthorizedToken {
wire_description, ..
} => Some(match wire_description {
Some(desc) => {
format!("Bearer error=\"invalid_token\", error_description=\"{desc}\"")
}
None => "Bearer error=\"invalid_token\"".to_string(),
}),
_ => None,
};
self.log_internal_detail();
let (status, code, message) = self.response_parts();
let body = ErrorEnvelope {
error: ErrorBody {
code: code.to_string(),
message,
details,
request_id: crate::request_context::request_id(),
},
};
let mut response = (status, axum::Json(body)).into_response();
if status == StatusCode::TOO_MANY_REQUESTS {
response.headers_mut().insert(
axum::http::header::RETRY_AFTER,
axum::http::HeaderValue::from_static("1"),
);
}
if let OrionError::ServiceUnavailable { reason, .. } = self
&& let Some(secs) = reason.retry_after_secs()
{
response.headers_mut().insert(
axum::http::header::RETRY_AFTER,
axum::http::HeaderValue::from(secs),
);
}
if let Some(challenge) = bearer_challenge
&& let Ok(value) = axum::http::HeaderValue::from_str(&challenge)
{
response
.headers_mut()
.insert(axum::http::header::WWW_AUTHENTICATE, value);
}
response
}
}
pub mod kind {
pub const CIRCUIT_OPEN: &str = "circuit_open";
pub const CONNECTOR_DETAIL: &str = "connector_detail";
pub const CHANNEL_RATE_LIMITED: &str = "channel_rate_limited";
pub const CHANNEL_FORBIDDEN: &str = "channel_forbidden";
pub const CHANNEL_CONFLICT: &str = "channel_conflict";
pub const CHANNEL_UNAVAILABLE: &str = "channel_unavailable";
pub const INTEGRITY_UNIQUE: &str = "integrity_unique";
pub const INTEGRITY_FOREIGN_KEY: &str = "integrity_foreign_key";
pub const INTEGRITY_NOT_NULL: &str = "integrity_not_null";
pub const INTEGRITY_CHECK: &str = "integrity_check";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IntegrityKind {
Unique,
ForeignKey,
NotNull,
Check,
}
pub fn channel_refused_dataflow_error(
status: StatusCode,
message: String,
) -> dataflow_rs::DataflowError {
let kind = match status.as_u16() {
429 => kind::CHANNEL_RATE_LIMITED,
403 => kind::CHANNEL_FORBIDDEN,
409 => kind::CHANNEL_CONFLICT,
_ => kind::CHANNEL_UNAVAILABLE,
};
dataflow_rs::DataflowError::service(kind, message)
.retryable(!matches!(status.as_u16(), 403 | 409))
.build()
}
pub fn circuit_open_dataflow_error(connector: &str, channel: &str) -> dataflow_rs::DataflowError {
dataflow_rs::DataflowError::service(
kind::CIRCUIT_OPEN,
format!("Circuit breaker open for connector '{connector}' on channel '{channel}'"),
)
.retryable(true)
.build()
}
pub fn connector_detail_error(detail: impl std::fmt::Display) -> dataflow_rs::DataflowError {
dataflow_rs::DataflowError::service(kind::CONNECTOR_DETAIL, "Request validation failed")
.detail(detail.to_string())
.build()
}
pub fn integrity_dataflow_error(
integrity: IntegrityKind,
detail: impl std::fmt::Display,
) -> dataflow_rs::DataflowError {
let (kind, message) = match integrity {
IntegrityKind::Unique => (
kind::INTEGRITY_UNIQUE,
"The request conflicts with an existing record",
),
IntegrityKind::ForeignKey => (
kind::INTEGRITY_FOREIGN_KEY,
"The request references a record that does not exist",
),
IntegrityKind::NotNull => (kind::INTEGRITY_NOT_NULL, "A required value is missing"),
IntegrityKind::Check => (
kind::INTEGRITY_CHECK,
"A value in the request is not allowed",
),
};
dataflow_rs::DataflowError::service(kind, message)
.detail(detail.to_string())
.retryable(false)
.build()
}
fn engine_error_response(e: &dataflow_rs::DataflowError) -> (StatusCode, &'static str, String) {
use dataflow_rs::DataflowError;
if let Some(k) = e.kind() {
let (status, code) = match k {
kind::CIRCUIT_OPEN => (StatusCode::SERVICE_UNAVAILABLE, codes::CIRCUIT_OPEN),
kind::CONNECTOR_DETAIL => (StatusCode::BAD_REQUEST, codes::VALIDATION_ERROR),
kind::CHANNEL_RATE_LIMITED => (StatusCode::TOO_MANY_REQUESTS, codes::RATE_LIMITED),
kind::CHANNEL_FORBIDDEN => (StatusCode::FORBIDDEN, codes::FORBIDDEN),
kind::CHANNEL_CONFLICT => (StatusCode::CONFLICT, codes::CONFLICT),
kind::CHANNEL_UNAVAILABLE => {
(StatusCode::SERVICE_UNAVAILABLE, codes::SERVICE_UNAVAILABLE)
}
kind::INTEGRITY_UNIQUE | kind::INTEGRITY_FOREIGN_KEY => {
(StatusCode::CONFLICT, codes::CONFLICT)
}
kind::INTEGRITY_NOT_NULL | kind::INTEGRITY_CHECK => {
(StatusCode::BAD_REQUEST, codes::VALIDATION_ERROR)
}
_ => {
tracing::error!(kind = %k, "unhandled service error kind; mapped to 500");
(StatusCode::INTERNAL_SERVER_ERROR, codes::ENGINE_ERROR)
}
};
return (status, code, e.to_string());
}
match e {
DataflowError::Validation(msg) => (
StatusCode::BAD_REQUEST,
codes::VALIDATION_ERROR,
msg.clone(),
),
DataflowError::Timeout(msg) => (StatusCode::GATEWAY_TIMEOUT, codes::TIMEOUT, msg.clone()),
other => {
tracing::error!(error = ?other, "unhandled DataflowError variant; mapped to 500");
(
StatusCode::INTERNAL_SERVER_ERROR,
codes::ENGINE_ERROR,
"An internal engine error occurred".to_string(),
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
#[test]
fn test_not_found_status() {
let err = OrionError::NotFound("workflow xyz".to_string());
let response = err.into_response();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[test]
fn test_unauthorized_status() {
let err = OrionError::Unauthorized("missing token".to_string());
let response = err.into_response();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn test_unauthorized_not_retryable() {
assert!(!OrionError::Unauthorized("bad".to_string()).is_retryable());
}
#[test]
fn test_conflict_status() {
let err = OrionError::Conflict("duplicate".to_string());
let response = err.into_response();
assert_eq!(response.status(), StatusCode::CONFLICT);
}
#[test]
fn test_internal_status() {
let err = OrionError::internal("something broke");
let response = err.into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn test_engine_validation_returns_400() {
let err = OrionError::Engine(dataflow_rs::DataflowError::Validation(
"bad input".to_string(),
));
let response = err.into_response();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn test_engine_timeout_returns_504() {
let err = OrionError::Engine(dataflow_rs::DataflowError::Timeout("timed out".to_string()));
let response = err.into_response();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[test]
fn test_config_error_status() {
let err = OrionError::Config {
message: "port must be > 0".to_string(),
};
let response = err.into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn a_closed_queue_is_service_unavailable_but_not_retryable() {
let err = OrionError::unavailable(Unavailable::QueueClosed, "queue is closed");
assert!(
!err.is_retryable(),
"a closed queue has no consumer — retrying cannot help"
);
let response = err.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(
!response
.headers()
.contains_key(axum::http::header::RETRY_AFTER),
"a 503 that will not clear must not invite a retry loop"
);
}
#[test]
fn an_at_capacity_refusal_carries_retry_after() {
let err = OrionError::unavailable(Unavailable::AtCapacity, "channel is at capacity");
assert!(err.is_retryable());
let response = err.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
response
.headers()
.get(axum::http::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok()),
Some("1"),
"backpressure clears as fast as the work in front of it"
);
}
#[test]
fn a_quarantined_channel_does_not_invite_a_retry() {
let err = OrionError::unavailable(
Unavailable::ChannelQuarantined,
"Channel 'orders' failed to load and is not being served",
);
assert!(!err.is_retryable());
let response = err.into_response();
assert!(
!response
.headers()
.contains_key(axum::http::header::RETRY_AFTER),
"it clears when an operator fixes the definition, not on a timer"
);
}
#[test]
fn test_internal_source_status() {
let source = std::io::Error::other("disk full");
let err = OrionError::Internal {
context: "Failed to write file".to_string(),
source: Some(Box::new(source)),
};
let response = err.into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn test_internal_source_preserves_chain() {
let source = std::io::Error::other("connection reset");
let err = OrionError::Internal {
context: "Failed to connect to database".to_string(),
source: Some(Box::new(source)),
};
assert!(std::error::Error::source(&err).is_some());
}
#[test]
fn test_retryable_storage() {
let err = OrionError::Storage(sqlx::Error::PoolTimedOut);
assert!(err.is_retryable());
}
#[test]
fn test_retryable_queue() {
assert!(OrionError::unavailable(Unavailable::AtCapacity, "queue is full").is_retryable());
}
#[test]
fn test_not_retryable_config() {
let err = OrionError::Config {
message: "invalid".to_string(),
};
assert!(!err.is_retryable());
}
#[test]
fn test_timeout_retryable() {
let err = OrionError::Timeout {
channel: "orders".to_string(),
timeout_ms: 5000,
};
assert!(err.is_retryable());
}
#[test]
fn test_service_unavailable_retryable() {
assert!(OrionError::unavailable(Unavailable::AtCapacity, "queue full").is_retryable());
}
#[test]
fn test_rate_limited_status() {
let err = OrionError::RateLimited("too many".to_string());
let response = err.into_response();
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
}
#[test]
fn test_rate_limited_retryable() {
assert!(OrionError::RateLimited("too many".to_string()).is_retryable());
}
#[test]
fn test_response_too_large_status() {
let err = OrionError::ResponseTooLarge("10MB exceeded".to_string());
let response = err.into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn test_response_too_large_not_retryable() {
assert!(!OrionError::ResponseTooLarge("too big".to_string()).is_retryable());
}
#[test]
fn test_serialization_error_status() {
let serde_err: serde_json::Error =
serde_json::from_str::<serde_json::Value>("invalid").expect_err("test");
let err = OrionError::Serialization(serde_err);
let response = err.into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn test_serialization_not_retryable() {
let serde_err: serde_json::Error =
serde_json::from_str::<serde_json::Value>("invalid").expect_err("test");
assert!(!OrionError::Serialization(serde_err).is_retryable());
}
#[test]
fn test_not_found_not_retryable() {
assert!(!OrionError::NotFound("x".to_string()).is_retryable());
}
#[test]
fn test_conflict_not_retryable() {
assert!(!OrionError::Conflict("dup".to_string()).is_retryable());
}
#[test]
fn test_internal_not_retryable() {
assert!(!OrionError::internal("err").is_retryable());
}
#[test]
fn test_internal_source_not_retryable() {
let err = OrionError::Internal {
context: "ctx".to_string(),
source: Some(Box::new(std::io::Error::other("err"))),
};
assert!(!err.is_retryable());
}
#[test]
fn test_storage_error_status() {
let err = OrionError::Storage(sqlx::Error::PoolTimedOut);
let response = err.into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn test_engine_generic_error_status() {
let err = OrionError::Engine(dataflow_rs::DataflowError::Unknown(
"unknown issue".to_string(),
));
let response = err.into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn test_error_display_messages() {
assert!(
OrionError::NotFound("workflow".to_string())
.to_string()
.contains("workflow")
);
assert!(OrionError::validation("bad").to_string().contains("bad"));
assert!(
OrionError::Conflict("dup".to_string())
.to_string()
.contains("dup")
);
assert!(
OrionError::unavailable(Unavailable::AtCapacity, "closed")
.to_string()
.contains("closed")
);
assert!(
OrionError::RateLimited("limit".to_string())
.to_string()
.contains("limit")
);
assert!(
OrionError::ResponseTooLarge("big".to_string())
.to_string()
.contains("big")
);
}
fn variant_name(err: &OrionError) -> &'static str {
match err {
OrionError::NotFound(_) => "NotFound",
OrionError::Validation { .. } => "Validation",
OrionError::Unauthorized(_) => "Unauthorized",
OrionError::UnauthorizedToken { .. } => "UnauthorizedToken",
OrionError::Forbidden(_) => "Forbidden",
OrionError::Conflict(_) => "Conflict",
OrionError::Internal { .. } => "Internal",
OrionError::Config { .. } => "Config",
OrionError::RateLimited(_) => "RateLimited",
OrionError::RateLimitKeyUnavailable(_) => "RateLimitKeyUnavailable",
OrionError::PayloadTooLarge(_) => "PayloadTooLarge",
OrionError::ResponseTooLarge(_) => "ResponseTooLarge",
OrionError::ServiceUnavailable { .. } => "ServiceUnavailable",
OrionError::Timeout { .. } => "Timeout",
OrionError::UnsupportedMediaType(_) => "UnsupportedMediaType",
OrionError::MethodNotAllowed(_) => "MethodNotAllowed",
OrionError::Storage(_) => "Storage",
OrionError::Engine(_) => "Engine",
OrionError::Serialization(_) => "Serialization",
}
}
const VARIANT_COUNT: usize = 19;
fn wire_contract() -> Vec<(OrionError, StatusCode, &'static str)> {
vec![
(
OrionError::NotFound("workflow xyz".into()),
StatusCode::NOT_FOUND,
"NOT_FOUND",
),
(
OrionError::UnauthorizedToken {
message: "Channel authentication failed".into(),
wire_description: Some("token expired"),
},
StatusCode::UNAUTHORIZED,
"UNAUTHORIZED",
),
(
OrionError::validation("invalid"),
StatusCode::BAD_REQUEST,
"VALIDATION_ERROR",
),
(
OrionError::Unauthorized("no key".into()),
StatusCode::UNAUTHORIZED,
"UNAUTHORIZED",
),
(
OrionError::Forbidden("nope".into()),
StatusCode::FORBIDDEN,
"FORBIDDEN",
),
(
OrionError::Conflict("dup".into()),
StatusCode::CONFLICT,
"CONFLICT",
),
(
OrionError::internal("boom"),
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
),
(
OrionError::Config {
message: "bad toml".into(),
},
StatusCode::INTERNAL_SERVER_ERROR,
"CONFIG_ERROR",
),
(
OrionError::RateLimited("slow down".into()),
StatusCode::TOO_MANY_REQUESTS,
"RATE_LIMITED",
),
(
OrionError::RateLimitKeyUnavailable("key logic failed".into()),
StatusCode::TOO_MANY_REQUESTS,
"RATE_LIMITED",
),
(
OrionError::PayloadTooLarge("body too big".into()),
StatusCode::PAYLOAD_TOO_LARGE,
"PAYLOAD_TOO_LARGE",
),
(
OrionError::ResponseTooLarge("big".into()),
StatusCode::INTERNAL_SERVER_ERROR,
"RESPONSE_TOO_LARGE",
),
(
OrionError::unavailable(Unavailable::AtCapacity, "closed"),
StatusCode::SERVICE_UNAVAILABLE,
"SERVICE_UNAVAILABLE",
),
(
OrionError::Timeout {
channel: "orders".into(),
timeout_ms: 500,
},
StatusCode::GATEWAY_TIMEOUT,
"TIMEOUT",
),
(
OrionError::UnsupportedMediaType("text/plain".into()),
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"UNSUPPORTED_MEDIA_TYPE",
),
(
OrionError::MethodNotAllowed("PUT".into()),
StatusCode::METHOD_NOT_ALLOWED,
"METHOD_NOT_ALLOWED",
),
(
OrionError::Storage(sqlx::Error::PoolTimedOut),
StatusCode::INTERNAL_SERVER_ERROR,
"STORAGE_ERROR",
),
(
OrionError::Engine(dataflow_rs::DataflowError::Unknown("x".into())),
StatusCode::INTERNAL_SERVER_ERROR,
"ENGINE_ERROR",
),
(
OrionError::Serialization(
serde_json::from_str::<i32>("not json").expect_err("test"),
),
StatusCode::INTERNAL_SERVER_ERROR,
"SERIALIZATION_ERROR",
),
]
}
#[test]
fn every_variant_states_its_status_and_code() {
for (err, want_status, want_code) in wire_contract() {
let name = variant_name(&err);
let (status, code, _) = err.response_parts();
assert_eq!(status, want_status, "{name} answers the wrong status");
assert_eq!(code, want_code, "{name} answers the wrong error code");
}
}
#[test]
fn the_wire_contract_covers_every_variant() {
let covered: std::collections::HashSet<&str> = wire_contract()
.iter()
.map(|(e, ..)| variant_name(e))
.collect();
assert_eq!(
covered.len(),
VARIANT_COUNT,
"wire_contract() covers {} of {VARIANT_COUNT} variants; a variant with no sample \
has its status and error code unpinned",
covered.len()
);
}
#[tokio::test]
async fn the_code_reaches_the_response_body() {
for (err, _, want_code) in wire_contract() {
let name = variant_name(&err);
let body = body_to_value(err.into_response()).await;
assert_eq!(
body["error"]["code"], want_code,
"{name} did not put its code on the wire (body: {body})"
);
}
}
async fn body_to_value(response: Response) -> Value {
let body_bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.expect("test");
serde_json::from_slice(&body_bytes).expect("test")
}
#[tokio::test]
async fn test_validation_variant_status_is_400() {
let err = OrionError::validation("invalid request");
let response = err.into_response();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_validation_no_details_omits_details_key() {
let err = OrionError::validation("invalid request");
let response = err.into_response();
let body = body_to_value(response).await;
let error = &body["error"];
assert_eq!(error["code"], "VALIDATION_ERROR");
assert_eq!(error["message"], "invalid request");
assert!(
error.get("details").is_none(),
"details must be omitted when empty (v0.1 compat)"
);
}
#[tokio::test]
async fn test_validation_with_field_emits_details_array() {
let err = OrionError::invalid_field(
"channel.protocol",
"ENUM_MISMATCH",
"unknown protocol 'REST'",
);
let response = err.into_response();
let body = body_to_value(response).await;
let details = &body["error"]["details"];
assert!(details.is_array(), "details should be an array");
let arr = details.as_array().expect("test");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["path"], "channel.protocol");
assert_eq!(arr[0]["code"], "ENUM_MISMATCH");
assert_eq!(arr[0]["message"], "unknown protocol 'REST'");
}
#[tokio::test]
async fn test_invalid_field_one_shot_constructor() {
let err = OrionError::invalid_field(
"channel.route_pattern",
"REQUIRED",
"required when protocol=\"rest\"",
);
let response = err.into_response();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = body_to_value(response).await;
let arr = body["error"]["details"].as_array().expect("test");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["path"], "channel.route_pattern");
assert_eq!(arr[0]["code"], "REQUIRED");
}
#[tokio::test]
async fn test_field_error_with_expected_and_got() {
let err = OrionError::Validation {
code: "VALIDATION_ERROR",
message: "bad enum".to_string(),
details: vec![
FieldError::new("channel.protocol", "ENUM_MISMATCH", "unknown protocol")
.with_expected(serde_json::json!(["rest", "http", "kafka"]))
.with_got(Value::String("REST".to_string())),
],
};
let response = err.into_response();
let body = body_to_value(response).await;
let detail = &body["error"]["details"][0];
assert_eq!(
detail["expected"],
serde_json::json!(["rest", "http", "kafka"])
);
assert_eq!(detail["got"], "REST");
}
#[tokio::test]
async fn test_v01_envelope_unchanged_for_non_validation_errors() {
let err = OrionError::NotFound("classic v0.1 message".to_string());
let response = err.into_response();
let body = body_to_value(response).await;
let error = &body["error"];
assert_eq!(error["code"], "NOT_FOUND");
assert_eq!(error["message"], "classic v0.1 message");
assert!(error.get("details").is_none());
}
#[test]
fn test_validation_not_retryable() {
let err = OrionError::invalid_field("y", "REQUIRED", "z");
assert!(!err.is_retryable());
}
#[tokio::test]
async fn test_engine_circuit_open_returns_503_with_code() {
let err = OrionError::Engine(circuit_open_dataflow_error("orders-api", "orders"));
let response = err.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = body_to_value(response).await;
assert_eq!(body["error"]["code"], "CIRCUIT_OPEN");
let message = body["error"]["message"].as_str().expect("test");
assert!(
!message.contains("orion.circuit_open"),
"no internal classification token may reach the client: {message}"
);
assert!(message.contains("orders-api") && message.contains("orders"));
}
#[tokio::test]
async fn test_channel_refusal_keeps_the_guards_status() {
for (status, code) in [
(StatusCode::TOO_MANY_REQUESTS, "RATE_LIMITED"),
(StatusCode::SERVICE_UNAVAILABLE, "SERVICE_UNAVAILABLE"),
(StatusCode::FORBIDDEN, "FORBIDDEN"),
(StatusCode::CONFLICT, "CONFLICT"),
] {
let err = OrionError::Engine(channel_refused_dataflow_error(
status,
"channel_call to 'billing': refused".to_string(),
));
let response = err.into_response();
assert_eq!(response.status(), status);
let body = body_to_value(response).await;
assert_eq!(body["error"]["code"], code, "{body}");
let message = body["error"]["message"].as_str().expect("test");
assert!(
!message.contains("orion.channel_refused"),
"no internal classification token may reach the client: {message}"
);
assert!(message.contains("channel_call to 'billing'"), "{message}");
}
}
#[tokio::test]
async fn test_downstream_429_is_not_reported_as_a_channel_refusal() {
let err = OrionError::Engine(dataflow_rs::DataflowError::http(429, "Too Many Requests"));
let response = err.into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn test_engine_circuit_open_is_retryable() {
let err = OrionError::Engine(circuit_open_dataflow_error("api", "orders"));
assert!(
err.is_retryable(),
"DLQ retry must classify a shed dependency as retryable"
);
}
#[tokio::test]
async fn test_downstream_503_is_not_reported_as_circuit_open() {
let err = OrionError::Engine(dataflow_rs::DataflowError::http(503, "Service Unavailable"));
let response = err.into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body = body_to_value(response).await;
assert_eq!(body["error"]["code"], "ENGINE_ERROR");
}
#[test]
fn no_classification_token_reaches_persisted_error_text() {
let errors = [
circuit_open_dataflow_error("billing-api", "orders"),
channel_refused_dataflow_error(
StatusCode::TOO_MANY_REQUESTS,
"channel_call to 'billing': over limit".to_string(),
),
connector_detail_error("operation 'delete' is disabled on connector 'prod-billing'"),
];
for e in errors {
let persisted = e.to_string();
assert!(
!persisted.contains("orion."),
"classification leaked into persisted text: {persisted}"
);
assert!(e.kind().is_some(), "each of these must carry a kind");
}
}
#[tokio::test]
async fn connector_detail_is_kept_off_the_wire_but_not_lost() {
let secret = "operation 'delete' is disabled on connector 'prod-billing-db'";
let e = connector_detail_error(secret);
assert_eq!(e.detail(), Some(secret));
assert!(!e.to_string().contains("prod-billing-db"));
let response = OrionError::Engine(e).into_response();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = body_to_value(response).await;
assert_eq!(body["error"]["code"], "VALIDATION_ERROR");
assert_eq!(body["error"]["message"], "Request validation failed");
assert!(
!body.to_string().contains("prod-billing-db"),
"connector inventory must not reach the data plane: {body}"
);
}
#[tokio::test]
async fn test_request_id_embedded_when_scoped() {
use crate::request_context::{REQUEST_CONTEXT, RequestContext};
let ctx = RequestContext {
request_id: "req-abc-123".to_string(),
..Default::default()
};
let response = REQUEST_CONTEXT
.scope(ctx, async { OrionError::validation("x").into_response() })
.await;
let body = body_to_value(response).await;
assert_eq!(body["error"]["request_id"], "req-abc-123");
}
#[tokio::test]
async fn test_request_id_absent_when_empty() {
use crate::request_context::{REQUEST_CONTEXT, RequestContext};
let response = REQUEST_CONTEXT
.scope(RequestContext::default(), async {
OrionError::validation("x").into_response()
})
.await;
let body = body_to_value(response).await;
assert!(body["error"].get("request_id").is_none());
}
}