fix(client): reconstruct typed errors from the server's SQLSTATE - #240
fix(client): reconstruct typed errors from the server's SQLSTATE#240laksamanakeris wants to merge 1 commit into
Conversation
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.
|
Following up on the test note in the description.
That run needs Changed crates: 787/787 pass, including the 13 tests added here. I was not able to finish a clean workspace-wide
One further check worth recording, since it is the thing that would most plausibly break: nothing in Deferring to CI for the authoritative full-gate result. |
Fixes #239.
What was wrong
check_errorandresponse_to_query_resultinnodedb-client/src/native/connection/mod.rsboth read a server error frame with.map(|e| e.message), keeping the message and droppinge.code. Every server-returned error therefore reached callers asErrorCode(9000)/ErrorDetails::Internal, no matter what SQLSTATE the server had computed for it.Because every predicate on
NodeDbErrorderives fromErrorDetails, this also broke classification:is_retriable(),is_not_found()andis_client_error()all reported the wrong answer. A cross-shard OCC abort is sent as40001specifically so the client retries it, and arrived as a non-retriable internal fault.What this changes
Adds
NodeDbError::from_sqlstate(sqlstate, message)innodedb-types, beside the SQLSTATE constants, so both transports can use it, and routes both client call sites through it.It returns a whole
NodeDbErrorrather than a bareErrorDetails, which would be the more obvious shape, becauseNodeDbError's fields arepub(super):nodedb-clientcannot assemble an error from parts, so a bareErrorDetailswould be unusable at the call site. Returning the whole error also keepscodeanddetailsin lockstep, which is the invariant every existing constructor maintains.Mapping policy
A SQLSTATE classifies a failure; it does not carry the operands. Mapping is therefore deliberately conservative, and the reasoning is recorded in the module doc rather than left implicit:
42P01becomesCollectionNotFound { collection: "" }. The failing name lives in the message, which is preserved verbatim; scraping it back out of prose would be brittle and would fabricate structured data. The variant, theErrorCodeand every derived predicate are exact.55P03is bothLOCK_NOT_AVAILABLE(retriable) andSTALE_READ_NOT_LEADER(not retriable);57P03and53400are likewise reused across two variants. Choosing either side would misreport retriability for the other.57P04needsNotLeader { leader_addr }and54001needsFanOutExceeded { shards_touched, limit }. Unlike a display name, a redirect address or a shard count is acted on, and an empty or zero value would be a fabricated instruction.42P07and0A000.Everything unmapped falls through to
NodeDbError::internal, which is exactly what every server error produced before. That is what makes the change non-regressive: a server emitting a SQLSTATE the client does not know yet is no worse off than today.Tests
42P01yieldsCollectionNotFound, notInternal, with the message intact.XX000) still yieldsInternal, asserted againstNodeDbError::internalas the baseline. A deliberately-unmapped real code (55P03,57P04) takes the same path.40001and57014are retriable again;42P01,42601,42883and42501are asserted not retriable, since reporting a client error as retriable would make a caller spin.Verified against a running server
Built
mainand drove the statements throughnodedb-clienton loopback. Before, all six error classes returnedErrorCode(9000). After,SELECT * FROM does_not_existandDROP TABLE does_not_existreturnErrorCode(1100)/CollectionNotFound. The transport control is unchanged: a dead port still givesSyncConnectionFailed/ErrorCode(3000)/ retriable.Two classes in that sample still return
Internal, and correctly so given this change's scope. Both are recorded in #239 as separate findings: the server'serror_to_nativesendsXX000forPlanErrorandUndefinedFunctionover the native protocol even though the pgwire path types them as42601and42883, and42P07has noErrorDetailsequivalent to map onto.Gates
cargo fmt --all --checkclean.cargo clippy -p nodedb-types -p nodedb-client --all-targets --all-features -- -D warningsclean. The workspace-wide run fails innodedb-vector/src/quantize/pq.rs(nonminimal_bool); confirmed identical on unmodifiedmainwith this local toolchain, so it is not from this change.cargo nextest runon the two changed crates: 787 pass, including the 13 added here.cargo nextest run -p nodedb-client-tests --all-features: 9 of 9 pass, includingnative_execute_sql_with_bound_params_round_trips, which drives the native protocol path both edited functions sit on. NeedsRUST_MIN_STACKset, as CI already does.Workspace-wide, two failures occur on this machine that also occur on unmodified
main, so neither comes from this change:nodedb-cluster-tests/tests/ilp_gateway_migration.rsdoes not compile (missing fields returning and rls_filters). Excluded from the run.nodedb data::executor::handlers::join::shuffle_join::tests::shuffle_grace_infeasible_budget_is_deterministic_errorfails. Verified by checking out28287607aclean and running that single test, where it fails identically. It is unrelated to error mapping: it assertscrate::Error::MemoryExhaustedfromdrive_grace_build, which is internal to the join executor and never touchesNodeDbError.I did not get a clean workspace-wide
--no-fail-fastrun locally; two attempts were killed bySIGTERMduring compilation. The one run that completed reached 4010 tests with the pre-existing failure above as the only one. Deferring to CI for the authoritative full-gate result.cargo deny checknot run:cargo-denyis not installed in this environment. This change adds no dependencies, so its inputs are unchanged.Also checked, since it is what would most plausibly break: nothing in
nodedb-clientornodedb-client-testsassertsis_internal,ErrorCode::INTERNAL,NDB-9000, orErrorDetails::Internal, so no existing test depended on the old flattening.