Skip to content

Commit f27bba7

Browse files
fix(client): reconstruct typed errors from the server's SQLSTATE
The native connection read a server error frame with `.map(|e| e.message)`, keeping the message and dropping `e.code`, so every server-returned error reached callers as `ErrorCode(9000)` / `ErrorDetails::Internal` regardless of what the server had classified it as. Every predicate on `NodeDbError` derives from `ErrorDetails`, so this also broke classification rather than just the error text: a cross-shard OCC abort is sent as `40001` specifically so the client retries it, and arrived as a non-retriable internal fault. Add `NodeDbError::from_sqlstate` beside the SQLSTATE constants so both transports can use it, and route both call sites through it. It returns a whole `NodeDbError` rather than a bare `ErrorDetails` because the struct's fields are `pub(super)`, so the client cannot assemble one from parts, and because it keeps `code` and `details` in lockstep the way every other constructor does. Mapping is conservative and the reasoning is recorded in the module doc: payload fields are left empty rather than scraped out of the message, codes overloaded across variants with different retriability are left unmapped, and so are codes whose variant carries a load-bearing payload. Anything unmapped falls through to `NodeDbError::internal`, which is what every server error produced before, so unknown codes are no worse off.
1 parent 2828760 commit f27bba7

3 files changed

Lines changed: 396 additions & 13 deletions

File tree

‎nodedb-client/src/native/connection/mod.rs‎

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ use std::sync::atomic::{AtomicU64, Ordering};
1313
use nodedb_types::error::{NodeDbError, NodeDbResult};
1414
use nodedb_types::protocol::{
1515
AuthMethod, CAP_COLUMNAR, CAP_CRDT, CAP_FTS, CAP_GRAPHRAG, CAP_SPATIAL, CAP_STREAMING,
16-
CAP_TIMESERIES, FRAME_HEADER_LEN, HelloAckFrame, HelloFrame, Limits, MAX_FRAME_SIZE,
17-
NativeRequest, NativeResponse, OpCode, PROTO_VERSION, RequestFields, ResponseStatus,
18-
TextFields,
16+
CAP_TIMESERIES, ErrorPayload, FRAME_HEADER_LEN, HelloAckFrame, HelloFrame, Limits,
17+
MAX_FRAME_SIZE, NativeRequest, NativeResponse, OpCode, PROTO_VERSION, RequestFields,
18+
ResponseStatus, TextFields,
1919
};
2020
use nodedb_types::result::QueryResult;
2121
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -449,24 +449,30 @@ fn io_err(e: std::io::Error) -> NodeDbError {
449449
NodeDbError::sync_connection_failed(format!("I/O: {e}"))
450450
}
451451

452+
/// Rebuild the typed error a server error frame describes.
453+
///
454+
/// The frame carries the server's own SQLSTATE alongside the message; reading
455+
/// only the message would discard the one machine-matchable fact in it and
456+
/// leave every server-side failure indistinguishable from an internal fault.
457+
/// A frame with no payload at all has no classification to recover, so it
458+
/// falls back to `fallback` as an internal error.
459+
fn error_from_frame(error: Option<ErrorPayload>, fallback: &str) -> NodeDbError {
460+
error.map_or_else(
461+
|| NodeDbError::internal(fallback),
462+
|e| NodeDbError::from_sqlstate(&e.code, &e.message),
463+
)
464+
}
465+
452466
fn check_error(resp: NativeResponse) -> NodeDbResult<()> {
453467
if resp.status == ResponseStatus::Error {
454-
let msg = resp
455-
.error
456-
.map(|e| e.message)
457-
.unwrap_or_else(|| "unknown error".into());
458-
return Err(NodeDbError::internal(msg));
468+
return Err(error_from_frame(resp.error, "unknown error"));
459469
}
460470
Ok(())
461471
}
462472

463473
fn response_to_query_result(resp: NativeResponse) -> NodeDbResult<QueryResult> {
464474
if resp.status == ResponseStatus::Error {
465-
let msg = resp
466-
.error
467-
.map(|e| e.message)
468-
.unwrap_or_else(|| "query failed".into());
469-
return Err(NodeDbError::internal(msg));
475+
return Err(error_from_frame(resp.error, "query failed"));
470476
}
471477
Ok(QueryResult {
472478
columns: resp.columns.unwrap_or_default(),
@@ -498,11 +504,67 @@ mod tests {
498504
assert_eq!(qr.rows[0][0].as_i64(), Some(42));
499505
}
500506

507+
/// An error frame must surface the server's classification, not just its
508+
/// prose: asserting only on the message is what let the SQLSTATE be
509+
/// dropped here unnoticed.
501510
#[test]
502511
fn response_to_query_result_error() {
503512
let resp = NativeResponse::error(1, "42P01", "not found");
504513
let err = response_to_query_result(resp).unwrap_err();
514+
505515
assert!(format!("{err}").contains("not found"));
516+
assert_eq!(
517+
err.code(),
518+
nodedb_types::error::ErrorCode::COLLECTION_NOT_FOUND
519+
);
520+
assert!(err.is_not_found());
521+
assert!(!err.is_internal());
522+
}
523+
524+
/// The same reconstruction happens on the non-query path, which has its
525+
/// own copy of the error branch.
526+
#[test]
527+
fn check_error_reconstructs_sqlstate() {
528+
let resp = NativeResponse::error(1, "42501", "authorization denied on t");
529+
let err = check_error(resp).unwrap_err();
530+
531+
assert!(err.is_auth_denied());
532+
assert_eq!(err.message(), "authorization denied on t");
533+
}
534+
535+
/// Retriability is the behavioural consequence of the classification: a
536+
/// serialization failure the client folds to `Internal` reads as
537+
/// permanent, so a retrying caller gives up on a transaction that would
538+
/// have succeeded.
539+
#[test]
540+
fn serialization_failure_frame_is_retriable() {
541+
let resp = NativeResponse::error(1, "40001", "transaction aborted");
542+
let err = response_to_query_result(resp).unwrap_err();
543+
544+
assert!(err.is_retriable());
545+
}
546+
547+
/// Non-regression: a SQLSTATE with no mapped variant behaves exactly as
548+
/// every server error did before the mapping existed.
549+
#[test]
550+
fn unmapped_sqlstate_frame_stays_internal() {
551+
let resp = NativeResponse::error(1, "XX000", "boom");
552+
let err = response_to_query_result(resp).unwrap_err();
553+
554+
assert!(err.is_internal());
555+
assert!(format!("{err}").contains("boom"));
556+
}
557+
558+
/// An error frame with no payload has no SQLSTATE to recover, so it keeps
559+
/// the call site's own fallback text.
560+
#[test]
561+
fn error_frame_without_payload_uses_fallback() {
562+
let mut resp = NativeResponse::error(1, "42P01", "not found");
563+
resp.error = None;
564+
let err = response_to_query_result(resp).unwrap_err();
565+
566+
assert!(err.is_internal());
567+
assert!(format!("{err}").contains("query failed"));
506568
}
507569

508570
#[test]

0 commit comments

Comments
 (0)