Skip to main content

aion_server/
error.rs

1//! `ServerError` taxonomy for server library modules.
2
3use std::borrow::Cow;
4use std::net::SocketAddr;
5
6use aion::EngineError;
7use aion_proto::WireError;
8use aion_store::StoreError;
9use thiserror::Error;
10
11/// Server-library error taxonomy.
12#[derive(Debug, Error)]
13pub enum ServerError {
14    /// Operator configuration could not be loaded or validated.
15    #[error("configuration error: {message}")]
16    Config {
17        /// Redacted, operator-facing failure message.
18        message: String,
19    },
20
21    /// A transport listener could not bind or start.
22    #[error("{transport} transport failed at {address}: {message}")]
23    TransportBind {
24        /// Transport name.
25        transport: &'static str,
26        /// Configured listener address.
27        address: SocketAddr,
28        /// Redacted, operator-facing failure message.
29        message: String,
30    },
31
32    /// A running transport task aborted: it panicked or was cancelled.
33    #[error("{transport} transport task failed: {message}")]
34    Transport {
35        /// Transport name.
36        transport: &'static str,
37        /// Redacted, operator-facing failure message.
38        message: String,
39    },
40
41    /// A termination-signal listener could not be installed or failed.
42    #[error("{listener} listener failed: {message}")]
43    SignalListener {
44        /// Listener name (`SIGTERM`, `SIGINT`, or the portable fallback).
45        listener: &'static str,
46        /// Redacted, operator-facing failure message.
47        message: String,
48    },
49
50    /// Namespace validation or authorization failed.
51    #[error("namespace error: {message}")]
52    Namespace {
53        /// Redacted namespace failure message.
54        message: String,
55    },
56
57    /// Engine call failed.
58    #[error("engine call failed: {source}")]
59    EngineCall {
60        /// Typed engine error returned by the embedded engine.
61        #[from]
62        source: EngineError,
63    },
64
65    /// Store backend call failed before an engine handle was available.
66    #[error("store backend failed: {source}")]
67    StoreBackend {
68        /// Typed store error returned by the configured backend.
69        #[from]
70        source: StoreError,
71    },
72
73    /// Streaming failure.
74    #[error("stream failure: {failure}")]
75    Stream {
76        /// Stream failure class.
77        failure: StreamFailure,
78    },
79
80    /// A scheduled activity could not be pushed to a worker.
81    #[error(
82        "worker dispatch failed for namespace {namespace}, activity type {activity_type}: {reason}"
83    )]
84    WorkerDispatch {
85        /// Namespace scoped before dispatch.
86        namespace: String,
87        /// Activity type requested by the engine.
88        activity_type: String,
89        /// Redacted dispatch failure reason.
90        reason: String,
91    },
92
93    /// The worker connection chosen for a dispatch was lost mid-flight: the
94    /// connection was already gone at push time, or it closed before the worker
95    /// sent its correlated push reply.
96    ///
97    /// This is DISTINCT from [`Self::WorkerDispatch`]: a `WorkerDispatch` covers a
98    /// genuine reply timeout (the worker is alive but slow), a no-worker-available
99    /// selection failure, or any other dispatch fault, all of which keep the
100    /// outbox's normal exponential backoff. A `WorkerConnectionLost` instead means
101    /// the chosen worker is gone (and has already been deregistered by liminal's
102    /// `on_worker_unregistered`), so the row can be re-armed for IMMEDIATE re-claim
103    /// to fail over to a live worker without waiting out the backoff. The outbox
104    /// dispatcher keys its fast-failover decision on this variant.
105    #[error("worker connection lost during dispatch on {channel}: {detail}")]
106    WorkerConnectionLost {
107        /// Row-derived dispatch channel for operator diagnostics.
108        channel: String,
109        /// Redacted, operator-facing description of how the connection was lost.
110        detail: String,
111    },
112
113    /// A lock was poisoned and the protected state cannot be trusted.
114    #[error("{resource} lock was poisoned")]
115    LockPoisoned {
116        /// Protected resource name.
117        resource: &'static str,
118    },
119
120    /// A failure already translated into the public wire taxonomy.
121    #[error("wire error: {wire}")]
122    Wire {
123        /// Stable wire error.
124        wire: WireError,
125    },
126}
127
128/// Bounded-stream and connection failure classes.
129#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
130pub enum StreamFailure {
131    /// Bounded per-connection buffer overflowed because the consumer lagged.
132    #[error("consumer lagged behind bounded buffer")]
133    Lagged,
134    /// Subscriber closed the connection.
135    #[error("subscriber connection closed")]
136    Closed,
137    /// Upstream engine event stream ended unexpectedly.
138    #[error("engine event stream closed")]
139    UpstreamClosed,
140}
141
142impl From<WireError> for ServerError {
143    fn from(wire: WireError) -> Self {
144        Self::Wire { wire }
145    }
146}
147
148impl ServerError {
149    /// Convert a server error that crosses a transport boundary into the stable
150    /// public wire taxonomy.
151    #[must_use]
152    pub fn to_wire_error(&self) -> WireError {
153        match self {
154            Self::Config { .. }
155            | Self::TransportBind { .. }
156            | Self::Transport { .. }
157            | Self::SignalListener { .. }
158            | Self::LockPoisoned { .. } => WireError::backend("server backend failure"),
159            Self::WorkerDispatch { .. } => WireError::backend("worker dispatch failed"),
160            Self::WorkerConnectionLost { .. } => {
161                WireError::backend("worker connection lost during dispatch")
162            }
163            Self::Namespace { message } => WireError::namespace_denied(message.clone()),
164            Self::EngineCall { source } => wire_from_engine(source),
165            Self::StoreBackend { source } => wire_from_store(source),
166            Self::Stream { failure } => match failure {
167                StreamFailure::Lagged => WireError::lagged("subscriber lagged behind"),
168                StreamFailure::Closed | StreamFailure::UpstreamClosed => {
169                    WireError::backend("event stream closed")
170                }
171            },
172            Self::Wire { wire } => wire.clone(),
173        }
174    }
175
176    /// Return true when this is an operator configuration failure.
177    #[must_use]
178    pub const fn is_config(&self) -> bool {
179        matches!(self, Self::Config { .. })
180    }
181
182    /// Construct a namespace-denied error without embedding authorization logic.
183    #[must_use]
184    pub fn namespace_denied(message: impl Into<String>) -> Self {
185        Self::Namespace {
186            message: message.into(),
187        }
188    }
189
190    /// Construct the loud, whole-registration rejection when a worker's advertised
191    /// `node` violates a `Pinned{L}` namespace's placement (Control-Plane Phase 2,
192    /// P2-I1). Names the offending namespace, the worker's advertised node (or
193    /// "none"), and the required label set, so the operator sees exactly why the
194    /// registration was refused. Carried on the namespace-denied wire code — a
195    /// registration refused on isolation grounds is a namespace-authorization
196    /// failure, not a transient dispatch error.
197    #[must_use]
198    pub fn placement_admission_denied(
199        namespace: &str,
200        worker_node: Option<&str>,
201        required: &std::collections::BTreeSet<String>,
202    ) -> Self {
203        let node = worker_node.unwrap_or("none");
204        let required = required
205            .iter()
206            .map(String::as_str)
207            .collect::<Vec<_>>()
208            .join(", ");
209        Self::namespace_denied(format!(
210            "worker registration rejected: namespace {namespace} is Pinned to node label(s) \
211             [{required}] but the worker advertises node {node}, which is not in the required set"
212        ))
213    }
214
215    /// Construct a deploy-authorization denial carried on the dedicated
216    /// `deploy_denied` wire code (deploy is not a namespace operation).
217    #[must_use]
218    pub fn deploy_denied(message: impl Into<String>) -> Self {
219        Self::Wire {
220            wire: WireError::deploy_denied(message),
221        }
222    }
223
224    /// Construct a lagged-stream error.
225    #[must_use]
226    pub const fn lagged_stream() -> Self {
227        Self::Stream {
228            failure: StreamFailure::Lagged,
229        }
230    }
231
232    /// Construct a worker-dispatch error.
233    #[must_use]
234    pub fn worker_dispatch(
235        namespace: impl Into<String>,
236        activity_type: impl Into<String>,
237        reason: impl Into<String>,
238    ) -> Self {
239        Self::WorkerDispatch {
240            namespace: namespace.into(),
241            activity_type: activity_type.into(),
242            reason: reason.into(),
243        }
244    }
245
246    /// Construct a worker-connection-lost error for a dispatch whose chosen
247    /// worker connection was gone at push time or closed before replying.
248    #[must_use]
249    pub fn worker_connection_lost(channel: impl Into<String>, detail: impl Into<String>) -> Self {
250        Self::WorkerConnectionLost {
251            channel: channel.into(),
252            detail: detail.into(),
253        }
254    }
255
256    /// Return true when this is a lost-worker-connection dispatch failure.
257    ///
258    /// The outbox dispatcher keys its fast cross-node failover on this: a lost
259    /// connection means the worker is gone (already deregistered), so the row is
260    /// re-armed for immediate re-claim instead of waiting out the retry backoff.
261    #[must_use]
262    pub const fn is_worker_connection_lost(&self) -> bool {
263        matches!(self, Self::WorkerConnectionLost { .. })
264    }
265
266    /// Construct a lock-poison error at the lock boundary.
267    #[must_use]
268    pub const fn lock_poisoned(resource: &'static str) -> Self {
269        Self::LockPoisoned { resource }
270    }
271}
272
273/// Stable structured error metadata for tracing events.
274#[derive(Clone)]
275pub struct ErrorTraceFields<'a> {
276    /// Outer error type recorded in the `error_type` tracing field.
277    pub error_type: Cow<'a, str>,
278    /// Optional inner store error type for `StoreError` records.
279    pub store_error_type: Option<&'static str>,
280    /// Human-readable reason safe for operator logs.
281    pub reason: &'a dyn std::fmt::Display,
282}
283
284impl ServerError {
285    /// Return stable typed fields for structured error logging.
286    #[must_use]
287    pub fn trace_fields(&self) -> ErrorTraceFields<'_> {
288        match self {
289            Self::Config { message } => ErrorTraceFields {
290                error_type: Cow::Borrowed("Config"),
291                store_error_type: None,
292                reason: message,
293            },
294            Self::TransportBind { message, .. } => ErrorTraceFields {
295                error_type: Cow::Borrowed("TransportBind"),
296                store_error_type: None,
297                reason: message,
298            },
299            Self::Transport { message, .. } => ErrorTraceFields {
300                error_type: Cow::Borrowed("Transport"),
301                store_error_type: None,
302                reason: message,
303            },
304            Self::SignalListener { message, .. } => ErrorTraceFields {
305                error_type: Cow::Borrowed("SignalListener"),
306                store_error_type: None,
307                reason: message,
308            },
309            Self::Namespace { message } => ErrorTraceFields {
310                error_type: Cow::Borrowed("Namespace"),
311                store_error_type: None,
312                reason: message,
313            },
314            Self::EngineCall { source } => engine_trace_fields(source),
315            Self::StoreBackend { source } => store_trace_fields(source),
316            Self::Stream { failure } => ErrorTraceFields {
317                error_type: Cow::Borrowed("Stream"),
318                store_error_type: None,
319                reason: failure,
320            },
321            Self::WorkerDispatch { reason, .. } => ErrorTraceFields {
322                error_type: Cow::Borrowed("WorkerDispatch"),
323                store_error_type: None,
324                reason,
325            },
326            Self::WorkerConnectionLost { detail, .. } => ErrorTraceFields {
327                error_type: Cow::Borrowed("WorkerConnectionLost"),
328                store_error_type: None,
329                reason: detail,
330            },
331            Self::LockPoisoned { resource } => ErrorTraceFields {
332                error_type: Cow::Borrowed("LockPoisoned"),
333                store_error_type: None,
334                reason: resource,
335            },
336            Self::Wire { wire } => ErrorTraceFields {
337                error_type: wire
338                    .error_type
339                    .as_deref()
340                    .map_or_else(|| Cow::Borrowed(wire.code.as_str()), Cow::Borrowed),
341                store_error_type: None,
342                reason: wire,
343            },
344        }
345    }
346}
347
348fn engine_trace_fields(source: &EngineError) -> ErrorTraceFields<'_> {
349    match source {
350        EngineError::WorkflowNotFound { .. } => simple_engine_fields("WorkflowNotFound", source),
351        EngineError::InvalidState { .. } => simple_engine_fields("InvalidState", source),
352        EngineError::ScheduleNotFound { .. } => simple_engine_fields("ScheduleNotFound", source),
353        EngineError::ShuttingDown => simple_engine_fields("ShuttingDown", source),
354        EngineError::Store(store) => store_trace_fields(store),
355        EngineError::Durability(durability) => match durability {
356            aion::durability::DurabilityError::Store(store) => store_trace_fields(store),
357            aion::durability::DurabilityError::NonDeterminism(_)
358            | aion::durability::DurabilityError::HistoryShape { .. }
359            | aion::durability::DurabilityError::SearchAttribute(_) => {
360                simple_engine_fields("Durability", source)
361            }
362        },
363        EngineError::MissingStore => simple_engine_fields("MissingStore", source),
364        EngineError::MissingVisibilityStore => {
365            simple_engine_fields("MissingVisibilityStore", source)
366        }
367        EngineError::ConflictingEventPublisher => {
368            simple_engine_fields("ConflictingEventPublisher", source)
369        }
370        EngineError::EventStreaming(_) => simple_engine_fields("EventStreaming", source),
371        EngineError::Load { .. } => simple_engine_fields("Load", source),
372        EngineError::UnknownVersion { .. } => simple_engine_fields("UnknownVersion", source),
373        EngineError::VersionPinned { .. } => simple_engine_fields("VersionPinned", source),
374        EngineError::RouteActive { .. } => simple_engine_fields("RouteActive", source),
375        EngineError::ManifestMismatch { .. } => simple_engine_fields("ManifestMismatch", source),
376        EngineError::Package(_) => simple_engine_fields("Package", source),
377        EngineError::Schedule { .. } => simple_engine_fields("Schedule", source),
378        EngineError::Runtime { .. } => simple_engine_fields("Runtime", source),
379        EngineError::RegistryPoisoned => simple_engine_fields("RegistryPoisoned", source),
380        EngineError::CatalogPoisoned => simple_engine_fields("CatalogPoisoned", source),
381        EngineError::NifRegistration { .. } => simple_engine_fields("NifRegistration", source),
382        EngineError::SignalRouter(_) => simple_engine_fields("SignalRouter", source),
383        EngineError::Query(query) => simple_engine_fields(query_error_type(query), source),
384    }
385}
386
387/// Trace discriminator for live-query dispatch failures.
388fn query_error_type(source: &aion::QueryError) -> &'static str {
389    match source {
390        aion::QueryError::UnknownQuery(_) => "UnknownQuery",
391        aion::QueryError::Timeout => "QueryTimeout",
392        aion::QueryError::NotRunning(_) => "QueryNotRunning",
393        aion::QueryError::Unknown(_) => "QueryUnknownWorkflow",
394        aion::QueryError::ReplyDropped => "QueryReplyDropped",
395        aion::QueryError::HandlerFailed { .. } => "QueryFailed",
396        aion::QueryError::Engine(_) => "QueryEngine",
397    }
398}
399
400fn simple_engine_fields<'a>(
401    error_type: &'static str,
402    source: &'a EngineError,
403) -> ErrorTraceFields<'a> {
404    ErrorTraceFields {
405        error_type: Cow::Borrowed(error_type),
406        store_error_type: None,
407        reason: source,
408    }
409}
410
411fn store_trace_fields(source: &StoreError) -> ErrorTraceFields<'_> {
412    ErrorTraceFields {
413        error_type: Cow::Borrowed("StoreError"),
414        store_error_type: Some(store_error_type(source)),
415        reason: source,
416    }
417}
418
419fn store_error_type(source: &StoreError) -> &'static str {
420    match source {
421        StoreError::SequenceConflict { .. } => "SequenceConflict",
422        StoreError::NotFound { .. } => "NotFound",
423        StoreError::NotOwner { .. } => "NotOwner",
424        StoreError::Backend(_) => "Backend",
425        StoreError::Serialization(_) => "Serialization",
426    }
427}
428
429fn wire_from_engine(source: &EngineError) -> WireError {
430    match source {
431        EngineError::WorkflowNotFound { .. } => {
432            WireError::not_found_with_type("WorkflowNotFound", source.to_string())
433        }
434        // Reopen precondition failure (AD-012): the run is not a reopenable
435        // terminal. Carried on the dedicated `invalid_state` wire code (gRPC
436        // FailedPrecondition / HTTP 409), distinct from NotFound and Backend.
437        EngineError::InvalidState { reason } => {
438            WireError::invalid_state_with_type("InvalidState", reason.clone())
439        }
440        EngineError::ScheduleNotFound { .. } => {
441            WireError::not_found_with_type("ScheduleNotFound", source.to_string())
442        }
443        EngineError::ShuttingDown => {
444            WireError::not_running_with_type("ShuttingDown", source.to_string())
445        }
446        EngineError::Store(store) => wire_from_store(store),
447        EngineError::Durability(durability) => match durability {
448            aion::durability::DurabilityError::Store(store) => wire_from_store(store),
449            aion::durability::DurabilityError::NonDeterminism(_)
450            | aion::durability::DurabilityError::HistoryShape { .. }
451            | aion::durability::DurabilityError::SearchAttribute(_) => {
452                WireError::backend_with_type("Durability", source.to_string())
453            }
454        },
455        EngineError::MissingStore => {
456            WireError::backend_with_type("MissingStore", source.to_string())
457        }
458        EngineError::MissingVisibilityStore => {
459            WireError::backend_with_type("MissingVisibilityStore", source.to_string())
460        }
461        EngineError::ConflictingEventPublisher => {
462            WireError::backend_with_type("ConflictingEventPublisher", source.to_string())
463        }
464        EngineError::EventStreaming(_) => {
465            WireError::backend_with_type("EventStreaming", source.to_string())
466        }
467        EngineError::Load { .. } => WireError::backend_with_type("Load", source.to_string()),
468        // Deploy-surface refusals (the §2.4 mapping table): unknown
469        // `(type, version)` is not-found; route-active and pinned versions
470        // are state conflicts carried by the dedicated `version_pinned`
471        // code; a same-hash-different-manifest archive is invalid input.
472        EngineError::UnknownVersion { .. } => {
473            WireError::not_found_with_type("UnknownVersion", source.to_string())
474        }
475        EngineError::VersionPinned { .. } => {
476            WireError::version_pinned(source.to_string()).with_error_type("VersionPinned")
477        }
478        EngineError::RouteActive { .. } => {
479            WireError::version_pinned(source.to_string()).with_error_type("RouteActive")
480        }
481        EngineError::ManifestMismatch { .. } => {
482            WireError::invalid_input(source.to_string()).with_error_type("ManifestMismatch")
483        }
484        EngineError::Package(_) => WireError::backend_with_type("Package", source.to_string()),
485        EngineError::Schedule { .. } => {
486            WireError::backend_with_type("Schedule", source.to_string())
487        }
488        EngineError::Runtime { .. } => WireError::backend_with_type("Runtime", source.to_string()),
489        EngineError::CatalogPoisoned => {
490            WireError::backend_with_type("CatalogPoisoned", source.to_string())
491        }
492        EngineError::RegistryPoisoned => {
493            WireError::backend_with_type("RegistryPoisoned", source.to_string())
494        }
495        EngineError::NifRegistration { .. } => {
496            WireError::backend_with_type("NifRegistration", source.to_string())
497        }
498        EngineError::SignalRouter(_) => {
499            WireError::backend_with_type("SignalRouter", source.to_string())
500        }
501        EngineError::Query(query) => wire_from_query(query, source),
502    }
503}
504
505/// Wire mapping for live-query dispatch failures (per the #45 brief).
506///
507/// `ReplyDropped` maps to `not_running` per decision Q3: the workflow ended
508/// before answering. `HandlerFailed` maps to the dedicated `query_failed`
509/// code per decision Q1(b).
510fn wire_from_query(query: &aion::QueryError, source: &EngineError) -> WireError {
511    match query {
512        aion::QueryError::UnknownQuery(_) => WireError::unknown_query(source.to_string()),
513        aion::QueryError::Timeout => WireError::query_timeout(source.to_string()),
514        aion::QueryError::NotRunning(_) | aion::QueryError::ReplyDropped => {
515            WireError::not_running_with_type(query_error_type(query), source.to_string())
516        }
517        aion::QueryError::Unknown(_) => {
518            WireError::not_found_with_type(query_error_type(query), source.to_string())
519        }
520        aion::QueryError::HandlerFailed { .. } => {
521            WireError::query_failed(source.to_string()).with_error_type(query_error_type(query))
522        }
523        aion::QueryError::Engine(_) => {
524            WireError::backend_with_type(query_error_type(query), source.to_string())
525        }
526    }
527}
528
529fn wire_from_store(source: &StoreError) -> WireError {
530    match source {
531        StoreError::SequenceConflict { .. } => WireError::new_with_type(
532            aion_proto::WireErrorCode::SequenceConflict,
533            "SequenceConflict",
534            source.to_string(),
535        ),
536        StoreError::NotFound { .. } => {
537            WireError::not_found_with_type("NotFound", source.to_string())
538        }
539        StoreError::NotOwner { .. } => {
540            WireError::not_owner(source.to_string()).with_error_type("NotOwner")
541        }
542        StoreError::Backend(_) => WireError::backend_with_type("Backend", source.to_string()),
543        StoreError::Serialization(_) => {
544            WireError::backend_with_type("Serialization", source.to_string())
545        }
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::{ServerError, StreamFailure};
552    use aion::{EngineError, QueryError, engine_seam::EngineSeamError};
553    use aion_core::WorkflowId;
554    use aion_proto::WireErrorCode;
555
556    fn assert_send_sync<T: Send + Sync>() {}
557
558    #[test]
559    fn server_error_is_send_sync() {
560        assert_send_sync::<ServerError>();
561    }
562
563    #[test]
564    fn lagged_stream_maps_to_wire_lagged() {
565        let error = ServerError::Stream {
566            failure: StreamFailure::Lagged,
567        };
568
569        assert_eq!(error.to_wire_error().code, WireErrorCode::Lagged);
570    }
571
572    /// R-0: a fenced quorum write (`StoreError::NotOwner`) must surface as the
573    /// typed, retryable `NotOwner` wire code with a `NotOwner` `error_type`, NOT
574    /// the opaque `Backend` it used to collapse into.
575    #[test]
576    fn not_owner_store_error_maps_to_wire_not_owner() {
577        let error = ServerError::StoreBackend {
578            source: aion_store::StoreError::NotOwner { shard: 3 },
579        };
580        let wire = error.to_wire_error();
581        assert_eq!(wire.code, WireErrorCode::NotOwner);
582        assert_eq!(wire.error_type.as_deref(), Some("NotOwner"));
583    }
584
585    fn workflow_id() -> WorkflowId {
586        WorkflowId::new(uuid::Uuid::from_u128(7))
587    }
588
589    fn query_wire(query: QueryError) -> aion_proto::WireError {
590        ServerError::EngineCall {
591            source: EngineError::Query(query),
592        }
593        .to_wire_error()
594    }
595
596    /// Pins the wire mapping for every `QueryError` arm (#45 decisions
597    /// Q1(b)/Q3): adding a variant breaks the exhaustive list below until its
598    /// mapping is decided and pinned here.
599    #[test]
600    fn every_query_error_arm_maps_to_its_pinned_wire_code() {
601        let arms: Vec<(QueryError, WireErrorCode, Option<&str>)> = vec![
602            (
603                QueryError::UnknownQuery(String::from("state")),
604                WireErrorCode::UnknownQuery,
605                None,
606            ),
607            (QueryError::Timeout, WireErrorCode::QueryTimeout, None),
608            (
609                QueryError::NotRunning(workflow_id()),
610                WireErrorCode::NotRunning,
611                Some("QueryNotRunning"),
612            ),
613            (
614                QueryError::Unknown(workflow_id()),
615                WireErrorCode::NotFound,
616                Some("QueryUnknownWorkflow"),
617            ),
618            // Q3: the workflow ended before answering — not_running, not backend.
619            (
620                QueryError::ReplyDropped,
621                WireErrorCode::NotRunning,
622                Some("QueryReplyDropped"),
623            ),
624            // Q1(b): the dedicated query_failed wire code.
625            (
626                QueryError::HandlerFailed {
627                    message: String::from("handler raised"),
628                },
629                WireErrorCode::QueryFailed,
630                Some("QueryFailed"),
631            ),
632            (
633                QueryError::Engine(EngineSeamError::Delivery {
634                    reason: String::from("mailbox closed"),
635                }),
636                WireErrorCode::Backend,
637                Some("QueryEngine"),
638            ),
639        ];
640
641        // Count-lock: the pin list must grow with the enum. The exhaustive
642        // match below numbers every variant; a new variant breaks the match
643        // first, and updating the match without pinning the new mapping
644        // breaks this assertion.
645        let variant_count = arms
646            .iter()
647            .map(|(query, _, _)| match query {
648                QueryError::UnknownQuery(_) => 0,
649                QueryError::Timeout => 1,
650                QueryError::NotRunning(_) => 2,
651                QueryError::Unknown(_) => 3,
652                QueryError::ReplyDropped => 4,
653                QueryError::HandlerFailed { .. } => 5,
654                QueryError::Engine(_) => 6,
655            })
656            .collect::<std::collections::BTreeSet<usize>>()
657            .len();
658        assert_eq!(
659            arms.len(),
660            variant_count,
661            "every QueryError variant must appear exactly once in the pin list",
662        );
663        assert_eq!(variant_count, 7, "pin list must cover all 7 variants");
664
665        for (query, expected_code, expected_type) in arms {
666            let wire = query_wire(query.clone());
667            assert_eq!(
668                wire.code, expected_code,
669                "{query:?} must map to {expected_code:?}",
670            );
671            assert_eq!(
672                wire.error_type.as_deref(),
673                expected_type,
674                "{query:?} must carry error_type {expected_type:?}",
675            );
676        }
677    }
678
679    /// The trace discriminator for `HandlerFailed` matches the wire
680    /// `error_type` so operators can correlate logs with client branches.
681    #[test]
682    fn handler_failed_trace_fields_use_query_failed_type() {
683        let error = ServerError::EngineCall {
684            source: EngineError::Query(QueryError::HandlerFailed {
685                message: String::from("handler raised"),
686            }),
687        };
688
689        assert_eq!(error.trace_fields().error_type, "QueryFailed");
690    }
691}