feat(cubesql): Universally prefix all errors#10758
Conversation
|
Claude finished @MazterQyou's task in 6m 3s —— View job Code Review: Universally prefix all errors
SummaryThis is a well-structured PR that introduces a systematic error taxonomy across CubeSQL. The core idea — replacing the old 2-variant What works well
Issues & suggestionsMedium priority:
Low priority / nits:
TestingThe test changes look correct:
SecurityNo security concerns. The OverallSolid improvement to error handling infrastructure. The error taxonomy is well-thought-out and the conversion pipelines are correct. The main risk area is ensuring downstream consumers (logs, HTTP responses, PG wire protocol) get the right level of detail — the inline comments highlight the specific spots to verify. |
| } | ||
|
|
||
| impl From<rust_decimal::Error> for CubeError { | ||
| fn from(v: rust_decimal::Error) -> Self { |
There was a problem hiding this comment.
Minor: double-description in formatted message — This produces CubeError::internal("Decimal Error: ..."), which when displayed via CubeError::Display becomes "Internal Error: Decimal Error: ...". The nested prefix reads a bit awkwardly. Consider either:
- Dropping the "Decimal Error: " prefix and relying on the
Internal Error:prefix from Display, or - Using a dedicated error variant if decimal errors need distinct handling
This is very minor and non-blocking.
|
|
||
| fn cube_to_error_response(e: &CubeError) -> protocol::ErrorResponse { | ||
| let message = e.to_string(); | ||
| // Remove `Error: ` prefix that can come from JS | ||
| let message = if let Some(message) = message.strip_prefix("Error: ") { | ||
| message.to_string() | ||
| } else { | ||
| message | ||
| }; | ||
| protocol::ErrorResponse::error(protocol::ErrorCode::InternalError, message) | ||
| } | ||
|
|
||
| fn df_to_error_response(e: &DataFusionError) -> protocol::ErrorResponse { | ||
| match e { | ||
| DataFusionError::ArrowError(arrow_err) => { | ||
| return Self::arrow_to_error_response(arrow_err); | ||
| fn cube_to_error_response(e: CubeError) -> protocol::ErrorResponse { | ||
| match e.cause { | ||
| CubeErrorCauseType::User(_) => protocol::ErrorResponse::error( | ||
| protocol::ErrorCode::InvalidSqlStatement, | ||
| e.to_string(), | ||
| ), | ||
| CubeErrorCauseType::Internal(_) => { | ||
| protocol::ErrorResponse::error(protocol::ErrorCode::InternalError, e.to_string()) | ||
| } | ||
| DataFusionError::External(err) => { | ||
| if let Some(cube_err) = err.downcast_ref::<CubeError>() { | ||
| return Self::cube_to_error_response(cube_err); | ||
| } | ||
| CubeErrorCauseType::RESTAPI(_) => { | ||
| protocol::ErrorResponse::error(protocol::ErrorCode::SystemError, e.to_string()) | ||
| } | ||
| _ => {} | ||
| } | ||
| protocol::ErrorResponse::error( | ||
| protocol::ErrorCode::InternalError, | ||
| format!("Post-processing Error: {}", e), | ||
| ) | ||
| } | ||
|
|
||
| fn arrow_to_error_response(e: &ArrowError) -> protocol::ErrorResponse { | ||
| match e { | ||
| ArrowError::ExternalError(err) => { | ||
| if let Some(df_err) = err.downcast_ref::<DataFusionError>() { | ||
| return Self::df_to_error_response(df_err); | ||
| } | ||
| if let Some(cube_err) = err.downcast_ref::<CubeError>() { | ||
| return Self::cube_to_error_response(cube_err); | ||
| } | ||
| CubeErrorCauseType::SQLParser(_) => { | ||
| protocol::ErrorResponse::error(protocol::ErrorCode::SyntaxError, e.to_string()) | ||
| } | ||
| CubeErrorCauseType::Unsupported(_) => protocol::ErrorResponse::error( | ||
| protocol::ErrorCode::FeatureNotSupported, | ||
| e.to_string(), | ||
| ), | ||
| CubeErrorCauseType::Planning(_) => protocol::ErrorResponse::error( | ||
| protocol::ErrorCode::SyntaxErrorOrAccessRuleViolation, | ||
| e.to_string(), | ||
| ), | ||
| CubeErrorCauseType::PostProcessing(_) => { | ||
| protocol::ErrorResponse::error(protocol::ErrorCode::DataException, e.to_string()) | ||
| } | ||
| CubeErrorCauseType::Rewrite(_) => { | ||
| protocol::ErrorResponse::error(protocol::ErrorCode::InternalError, e.to_string()) | ||
| } | ||
| CubeErrorCauseType::DatabaseExecution(_) => { | ||
| protocol::ErrorResponse::error(protocol::ErrorCode::SystemError, e.to_string()) | ||
| } | ||
| _ => {} | ||
| CubeErrorCauseType::ContinueWait => { | ||
| // Should never happen | ||
| protocol::ErrorResponse::error( | ||
| protocol::ErrorCode::SqlStatementNotYetComplete, | ||
| e.to_string(), |
There was a problem hiding this comment.
Good refactor — Consolidating the error-to-PG-code mapping into a single cube_to_error_response based on CubeErrorCauseType is much cleaner than the old recursive df_to_error_response / arrow_to_error_response chain that tried to unwrap errors manually.
A few observations on the PG error code mappings:
Planning→SyntaxErrorOrAccessRuleViolation(42000): This is the class-level code. Cube planning errors can be broader than syntax issues (e.g., join validation failures). Consider whetherInternalErrormight be more accurate for some planning failures.User→InvalidSqlStatement(26000): This PG code specifically means "invalid SQL statement name" (prepared statement naming). This mapping predates this PR, but if you're touching error codes anyway,42000might be semantically closer for general user errors.ContinueWait→SqlStatementNotYetComplete(03000): Creative use of this code. The comment "Should never happen" is reassuring — worth adding alog::warn!if it does trigger unexpectedly.
| CompilationError::User(right, _) => left == right, | ||
| _ => false, | ||
| }, | ||
| CompilationError::RESTAPI(left, _) => match other { | ||
| CompilationError::RESTAPI(right, _) => left == right, | ||
| _ => false, | ||
| }, | ||
| CompilationError::SQLParser(left, _) => match other { | ||
| CompilationError::SQLParser(right, _) => left == right, | ||
| _ => false, | ||
| }, | ||
| CompilationError::Unsupported(left, _) => match other { | ||
| CompilationError::Unsupported(right, _) => left == right, | ||
| _ => false, | ||
| }, | ||
| CompilationError::Planning(left, _) => match other { | ||
| CompilationError::Planning(right, _) => left == right, | ||
| _ => false, | ||
| }, | ||
| CompilationError::PostProcessing(left, _) => match other { | ||
| CompilationError::PostProcessing(right, _) => left == right, | ||
| _ => false, | ||
| }, | ||
| CompilationError::Rewrite(left, _) => match other { | ||
| CompilationError::Rewrite(right, _) => left == right, | ||
| _ => false, | ||
| }, | ||
| CompilationError::DatabaseExecution(left, _) => match other { | ||
| CompilationError::DatabaseExecution(right, _) => left == right, | ||
| _ => false, | ||
| }, | ||
| CompilationError::Fatal(left, _) => match other { | ||
| CompilationError::Fatal(right, _) => left == right, | ||
| _ => false, | ||
| }, | ||
| CompilationError::ContinueWait => matches!(other, CompilationError::ContinueWait), | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Suggestion: consider a macro or helper for PartialEq — This block is very repetitive. Each new variant adds 3 lines of identical structure. A macro like:
macro_rules! match_variant_eq {
($self:expr, $other:expr, $($Variant:ident),+) => {
match ($self, $other) {
$( (CompilationError::$Variant(left, ..), CompilationError::$Variant(right, ..)) => left == right, )+
(CompilationError::ContinueWait, CompilationError::ContinueWait) => true,
_ => false,
}
}
}could reduce this significantly. Same applies to to_backtrace(), to_backtrace_mut(), message(), with_message(), and with_meta(). Not blocking — just noting that the boilerplate will grow linearly with each new error variant.
| if let Err(e) = &result { | ||
| if e.message.to_lowercase().contains("continue wait") { | ||
| if throw_continue_wait { |
There was a problem hiding this comment.
Good: eliminates fragile string matching — The old code checked e.message.to_lowercase().contains("continue wait") to detect continue-wait signals. Using a dedicated CubeError::continue_wait() with a ContinueWait cause type is much more robust and self-documenting.
Note: there are still string-based "continue wait" checks in this file (lines 463 and 612). These handle errors coming from the JS side where you can't control the error type. Worth a follow-up comment explaining why these remain string-based (i.e., JS bridge limitation).
| Self { | ||
| code: match error.cause { | ||
| CubeErrorCauseType::User(_) => code, | ||
| CubeErrorCauseType::Internal(_) => { | ||
| HttpErrorCode::StatusCode(axum::http::StatusCode::INTERNAL_SERVER_ERROR) | ||
| } | ||
| _ => HttpErrorCode::StatusCode(axum::http::StatusCode::INTERNAL_SERVER_ERROR), | ||
| }, | ||
| message: match error.cause { | ||
| CubeErrorCauseType::User(_) => error.message, | ||
| CubeErrorCauseType::Internal(_) => "Internal Server Error".to_string(), | ||
| _ => "Internal Server Error".to_string(), | ||
| }, | ||
| } | ||
| } |
There was a problem hiding this comment.
Correct change — The catch-all _ => is necessary now that CubeErrorCauseType has many more variants. All non-User errors correctly return HTTP 500 with a generic message, preventing internal error details from leaking to HTTP clients.
Note: this means new error types like SQLParser, Planning, Unsupported etc. will all show "Internal Server Error" to HTTP callers, even though some (like SQLParser) are clearly user-caused. If this gateway is user-facing, you may want to expand which cause types show their real message (e.g., SQLParser, Unsupported, Planning could arguably be safe to expose).
ffc162c to
4eec296
Compare
Signed-off-by: Alex Qyoun-ae <4062971+MazterQyou@users.noreply.github.com>
4eec296 to
926c03e
Compare
Check List
Description of Changes Made
This PR makes an effort to universally prefix all errors with preset messages, and unwrap error onions wherever possible.