Skip to main content

noetl_server/
error.rs

1//! Error types for the NoETL Control Plane server.
2//!
3//! This module provides custom error types that implement `IntoResponse`
4//! for seamless integration with Axum handlers.
5
6use axum::{
7    Json,
8    http::StatusCode,
9    response::{IntoResponse, Response},
10};
11use serde_json::json;
12use thiserror::Error;
13
14/// Application-level errors for the control plane.
15#[derive(Error, Debug)]
16pub enum AppError {
17    /// Database error
18    #[error("Database error: {0}")]
19    Database(#[from] sqlx::Error),
20
21    /// Not found error
22    #[error("Resource not found: {0}")]
23    NotFound(String),
24
25    /// Validation error
26    #[error("Validation error: {0}")]
27    Validation(String),
28
29    /// Authentication error
30    #[error("Authentication error: {0}")]
31    Auth(String),
32
33    /// Authorization error
34    #[error("Authorization error: {0}")]
35    Forbidden(String),
36
37    /// Conflict error (e.g., duplicate resource)
38    #[error("Conflict: {0}")]
39    Conflict(String),
40
41    /// Bad request error
42    #[error("Bad request: {0}")]
43    BadRequest(String),
44
45    /// Internal server error
46    #[error("Internal error: {0}")]
47    Internal(String),
48
49    /// Configuration error
50    #[error("Configuration error: {0}")]
51    Config(String),
52
53    /// NATS messaging error
54    #[error("NATS error: {0}")]
55    Nats(String),
56
57    /// Serialization error
58    #[error("Serialization error: {0}")]
59    Serialization(#[from] serde_json::Error),
60
61    /// Template rendering error
62    #[error("Template error: {0}")]
63    Template(String),
64
65    /// Encryption error
66    #[error("Encryption error: {0}")]
67    Encryption(String),
68
69    /// External service error
70    #[error("External service error: {0}")]
71    ExternalService(String),
72
73    /// Parse error (YAML, JSON, etc.)
74    #[error("Parse error: {0}")]
75    Parse(String),
76
77    /// Secrets Wallet Phase 6c — residency policy violation: a server
78    /// in one region attempted to resolve a keychain entry whose
79    /// `residency: strict` policy region-locks it elsewhere.  Surfaces
80    /// to operators as HTTP 403 with a clear "credential X is
81    /// region-locked to Y; this server is in Z" message that NEVER
82    /// includes the value itself.
83    #[error(
84        "Residency violation: credential '{credential}' is region-locked to '{entry_region}'; this server is in '{server_region}'"
85    )]
86    ResidencyViolation {
87        credential: String,
88        entry_region: String,
89        server_region: String,
90    },
91
92    /// Secrets Wallet Phase 6e — cross-region broker is configured for
93    /// the credential's home region but unreachable / returned a non-2xx /
94    /// produced a malformed envelope.  HTTP 502 to the caller so they can
95    /// distinguish "policy says no" (403 from `ResidencyViolation`) from
96    /// "policy says yes via broker, but the broker is down" (transient).
97    ///
98    /// `cause` is a free-text reason — never a structured error chain
99    /// (we don't want `#[source]`-style trait-object plumbing inside an
100    /// HTTP-bounded error).
101    #[error("Cross-region broker {broker_url} unreachable: {cause}")]
102    CrossRegionUnreachable { broker_url: String, cause: String },
103}
104
105impl IntoResponse for AppError {
106    fn into_response(self) -> Response {
107        let (status, error_message) = match &self {
108            AppError::Database(e) => {
109                tracing::error!(error = %e, "Database error");
110                (StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
111            }
112            AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
113            AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()),
114            AppError::Auth(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
115            AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
116            AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
117            AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
118            AppError::Internal(msg) => {
119                tracing::error!(error = %msg, "Internal error");
120                (StatusCode::INTERNAL_SERVER_ERROR, msg.clone())
121            }
122            AppError::Config(msg) => {
123                tracing::error!(error = %msg, "Configuration error");
124                (StatusCode::INTERNAL_SERVER_ERROR, msg.clone())
125            }
126            AppError::Nats(msg) => {
127                tracing::error!(error = %msg, "NATS error");
128                (StatusCode::SERVICE_UNAVAILABLE, msg.clone())
129            }
130            AppError::Serialization(e) => {
131                tracing::error!(error = %e, "Serialization error");
132                (StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
133            }
134            AppError::Template(msg) => {
135                tracing::error!(error = %msg, "Template error");
136                (StatusCode::INTERNAL_SERVER_ERROR, msg.clone())
137            }
138            AppError::Encryption(msg) => {
139                tracing::error!(error = %msg, "Encryption error");
140                (StatusCode::INTERNAL_SERVER_ERROR, msg.clone())
141            }
142            AppError::ExternalService(msg) => {
143                tracing::warn!(error = %msg, "External service error");
144                (StatusCode::BAD_GATEWAY, msg.clone())
145            }
146            AppError::Parse(msg) => {
147                tracing::error!(error = %msg, "Parse error");
148                (StatusCode::BAD_REQUEST, msg.clone())
149            }
150            AppError::ResidencyViolation { .. } => {
151                tracing::warn!(error = %self, "Residency violation");
152                (StatusCode::FORBIDDEN, self.to_string())
153            }
154            AppError::CrossRegionUnreachable { .. } => {
155                tracing::warn!(error = %self, "Cross-region broker unreachable");
156                (StatusCode::BAD_GATEWAY, self.to_string())
157            }
158        };
159
160        let body = Json(json!({
161            "error": error_message,
162            "status": status.as_u16()
163        }));
164
165        (status, body).into_response()
166    }
167}
168
169/// Result type alias using AppError.
170pub type AppResult<T> = Result<T, AppError>;
171
172impl From<anyhow::Error> for AppError {
173    fn from(err: anyhow::Error) -> Self {
174        AppError::Internal(err.to_string())
175    }
176}
177
178impl From<envy::Error> for AppError {
179    fn from(err: envy::Error) -> Self {
180        AppError::Config(err.to_string())
181    }
182}
183
184impl From<crate::snowflake::SnowflakeError> for AppError {
185    fn from(err: crate::snowflake::SnowflakeError) -> Self {
186        // Snowflake errors are always 500-class: either the
187        // system clock is broken (ClockBeforeEpoch), the state
188        // mutex was poisoned (a panic happened inside generate(),
189        // which shouldn't), or the config wasn't validated at
190        // startup (MachineIdOutOfRange — should be impossible
191        // here since AppState::new validates).
192        AppError::Internal(err.to_string())
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_not_found_error() {
202        let err = AppError::NotFound("User not found".to_string());
203        assert_eq!(err.to_string(), "Resource not found: User not found");
204    }
205
206    #[test]
207    fn test_validation_error() {
208        let err = AppError::Validation("Invalid email".to_string());
209        assert_eq!(err.to_string(), "Validation error: Invalid email");
210    }
211}