Skip to main content

questdb/
error.rs

1/*******************************************************************************
2 *     ___                  _   ____  ____
3 *    / _ \ _   _  ___  ___| |_|  _ \| __ )
4 *   | | | | | | |/ _ \/ __| __| | | |  _ \
5 *   | |_| | |_| |  __/\__ \ |_| |_| | |_) |
6 *    \__\_\\__,_|\___||___/\__|____/|____/
7 *
8 *  Copyright (c) 2014-2019 Appsicle
9 *  Copyright (c) 2019-2025 QuestDB
10 *
11 *  Licensed under the Apache License, Version 2.0 (the "License");
12 *  you may not use this file except in compliance with the License.
13 *  You may obtain a copy of the License at
14 *
15 *  http://www.apache.org/licenses/LICENSE-2.0
16 *
17 *  Unless required by applicable law or agreed to in writing, software
18 *  distributed under the License is distributed on an "AS IS" BASIS,
19 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20 *  See the License for the specific language governing permissions and
21 *  limitations under the License.
22 *
23 ******************************************************************************/
24use std::convert::Infallible;
25use std::fmt::{Display, Formatter};
26
27macro_rules! fmt {
28    ($code:ident, $($arg:tt)*) => {
29        crate::error::Error::new(
30            crate::error::ErrorCode::$code,
31            format!($($arg)*))
32    }
33}
34
35/// Category of error.
36///
37/// This is the single, unified error category for the whole client: it spans
38/// both ingestion (writing into QuestDB) and queries (reading out). Not every
39/// variant can arise from every operation — the ingest path never emits the
40/// reader-only wire/cursor categories, and a query never emits the
41/// sender-only encode categories — but a caller handling errors from a
42/// `QuestDb` pool, which spans both directions, sees one category enum.
43///
44/// Accessible via Error's [`code`](Error::code) method.
45#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
46#[non_exhaustive]
47pub enum ErrorCode {
48    /// The host, port, or interface was incorrect.
49    CouldNotResolveAddr,
50
51    /// Called methods in the wrong order. E.g. `symbol` after `column`.
52    InvalidApiCall,
53
54    /// A network error connecting or flushing data out. **Transient** — obtain a
55    /// fresh connection (or let the pool rotate) and retry.
56    ///
57    /// The terminal, resend-required failure of the QWP/WebSocket
58    /// store-and-forward persisted symbol dictionary is a *distinct* code,
59    /// [`StoreResendRequired`](Self::StoreResendRequired), so a caller can tell it
60    /// apart from a retryable socket drop **by code**, without matching on the
61    /// error message text.
62    SocketError,
63
64    /// The TCP connect (dial) to the server exceeded the configured
65    /// `connect_timeout`. Distinct from [`SocketError`](Self::SocketError)
66    /// so a caller can tell a timed-out dial apart from a refused / reset
67    /// connection. Currently produced only by the QWP/WebSocket transport.
68    ConnectTimeout,
69
70    /// The string or symbol field is not encoded in valid UTF-8.
71    ///
72    /// *This error is reserved for the
73    /// [C and C++ API](https://github.com/questdb/c-questdb-client/).*
74    InvalidUtf8,
75
76    /// The table name or column name contains bad characters.
77    InvalidName,
78
79    /// The supplied timestamp is invalid.
80    InvalidTimestamp,
81
82    /// Error during the authentication process.
83    AuthError,
84
85    /// Error during TLS handshake.
86    TlsError,
87
88    /// The server does not support ILP-over-HTTP.
89    HttpNotSupported,
90
91    /// Error sent back from the server during flush.
92    ServerFlushError,
93
94    /// Bad configuration.
95    ConfigError,
96
97    /// There was an error serializing an array.
98    ArrayError,
99
100    /// Validate protocol version error.
101    ProtocolVersionError,
102
103    /// The supplied decimal is invalid.
104    InvalidDecimal,
105
106    /// QWP/WebSocket server rejection or terminal protocol violation.
107    ServerRejection,
108
109    /// `PooledSenderCore::flush_arrow_batch_*` was passed a column whose Arrow /
110    /// QuestDB kind cannot be persisted to a QuestDB table (e.g.
111    /// `ARRAY(LONG, N-D)` is query-result-only on the egress side and has
112    /// no QWP wire tag for ingress). Only emitted on the `arrow` feature.
113    ArrowUnsupportedColumnKind,
114
115    /// `PooledSenderCore::flush_arrow_batch_*` was passed a `RecordBatch` that
116    /// failed client-side structural validation (column count vs schema,
117    /// name encoding, ARROW C Data Interface invariants on a freshly
118    /// imported array, etc.). Only emitted on the `arrow` feature.
119    ArrowIngest,
120
121    /// A reconnectable failure on the column-major sender's flush/sync path
122    /// (transport error, EOF, or a closed connection). The operation has not
123    /// committed; the caller should obtain a fresh connection from the pool
124    /// (which rotates to a live endpoint) and re-drive from its source. Distinct
125    /// from terminal failures (auth / protocol / schema / server rejection),
126    /// which must not be retried.
127    FailoverRetry,
128
129    /// Every reachable endpoint completed its handshake but none advertised
130    /// a role matching the configured `target=` filter (e.g. `target=primary`
131    /// against an all-replica address list, or a 421 + `X-QuestDB-Role:
132    /// REPLICA` upgrade reject). Distinct from `SocketError` ("all endpoints
133    /// unreachable") so callers can tell "no primary elected yet" from
134    /// "everything is down".
135    RoleMismatch,
136
137    // --- Query / reader (egress) categories -----------------------------
138    // The categories below are emitted by the query path. They never arise
139    // from ingestion, but live in the same enum so a `QuestDb` handle (which
140    // spans both directions) speaks a single error vocabulary.
141    /// HTTP-upgrade or WebSocket handshake failure.
142    HandshakeError,
143
144    /// Server returned an unsupported QWP version, encoding, or capability.
145    UnsupportedServer,
146
147    /// Wire-format violation: bad magic, truncated frame, unknown discriminant,
148    /// invalid varint, symbol-dict reference miss, etc.
149    ProtocolError,
150
151    /// Bind parameter index, count, or value rejected client-side
152    /// (before the QUERY_REQUEST hits the wire). On the query path this
153    /// covers timestamp / decimal / geohash range failures alongside
154    /// everything else caught at bind time.
155    InvalidBind,
156
157    /// Server-reported QWP `SCHEMA_MISMATCH` (status `0x03`).
158    ServerSchemaMismatch,
159
160    /// Server-reported QWP `PARSE_ERROR` (status `0x05`).
161    ServerParseError,
162
163    /// Server-reported QWP `INTERNAL_ERROR` (status `0x06`).
164    ServerInternalError,
165
166    /// Server-reported QWP `SECURITY_ERROR` (status `0x08`).
167    ServerSecurityError,
168
169    /// Client-side limit hit (e.g. an array row exceeds the configured
170    /// per-row element cap).
171    LimitExceeded,
172
173    /// Server-reported QWP `LIMIT_EXCEEDED` (status `0x0B`).
174    ServerLimitExceeded,
175
176    /// Query was cancelled (locally or via server `CANCELLED` status `0x0A`).
177    Cancelled,
178
179    /// Mid-query failover was eligible but at least one batch had already
180    /// been delivered to the caller, and the cursor's `on_failover_reset`
181    /// callback was not installed. Failover would replay the query from the
182    /// start on the new endpoint, re-delivering already-consumed rows, so the
183    /// cursor terminates with this error instead of silently duplicating.
184    /// The caller must install `on_failover_reset` (and discard partial state
185    /// on each invocation) or re-run the query from scratch.
186    FailoverWouldDuplicate,
187
188    /// Streaming Arrow adapter saw a mid-stream schema change: a later
189    /// `RESULT_BATCH` decoded into an Arrow schema that differs from the
190    /// snapshot captured at adapter construction. The adapter is poisoned;
191    /// the underlying cursor remains usable and the caller may re-wrap it
192    /// with a fresh `as_arrow_reader()` call. Only emitted on the `arrow`
193    /// feature.
194    SchemaDrift,
195
196    /// `Cursor::as_arrow_reader()` was called on a stream that terminated
197    /// before any `RESULT_BATCH` was decoded — there is no schema to
198    /// snapshot. Recoverable: treat as a "no rows" result, or re-execute.
199    /// Only emitted on the `arrow` feature.
200    NoSchema,
201
202    /// Arrow C Data Interface export failed (e.g. arrow-rs rejected an
203    /// internal invariant on the produced `ArrayData`). Indicates a crate
204    /// bug; not user-recoverable. Only emitted on the `arrow` feature.
205    ArrowExport,
206
207    /// An irreducible QWP/WebSocket unit (the table schema plus a single
208    /// row block) exceeds the negotiated per-batch cap
209    /// (`min(max_buf_size, server X-QWP-Max-Batch-Size)`). Chunk publication
210    /// splits oversize inputs into smaller frames automatically, so this only
211    /// surfaces when splitting cannot make a frame fit. Distinct from
212    /// [`InvalidApiCall`](Self::InvalidApiCall) so callers can recognise it
213    /// without matching on the error message text.
214    BatchTooLarge,
215
216    /// The QWP/WebSocket store-and-forward persisted symbol dictionary is
217    /// unrecoverable, so the queued frames that reference it cannot be replayed:
218    /// a host/power crash tore the `.symbol-dict` side-file relative to the
219    /// queued frames, or it could not be written ahead of them. Retrying the
220    /// connection will not help — the affected rows must be **re-ingested from
221    /// their source**.
222    ///
223    /// Terminal, and **distinct from [`SocketError`](Self::SocketError)** (a
224    /// transient, retryable socket drop) so a caller can tell "resend from
225    /// source" apart from "reconnect and retry" **by code**, without matching on
226    /// the error message text. The sender's own reconnect/failover loops treat it
227    /// as terminal (they stop) rather than retrying it to their deadline.
228    StoreResendRequired,
229
230    /// The QWP/WebSocket connection-scoped symbol dictionary is full: interning
231    /// another distinct symbol would push it past its entry-count cap
232    /// (2,000,000, matching the server's ingress ceiling) or its cumulative
233    /// UTF-8 heap cap (256 MiB). The dictionary accumulates every distinct
234    /// symbol referenced across every column, chunk, and row-buffer flush on
235    /// one connection, and is only reset by discarding that connection.
236    ///
237    /// The failing frame is rejected before any byte reaches the wire and the
238    /// buffer is rolled back, so *that flush* loses nothing and already-interned
239    /// symbols keep flushing — but retrying a *new* symbol on the same sender can
240    /// never succeed. A full dictionary therefore **retires the connection on
241    /// return**: a pooled sender is dropped rather than recycled (so the next
242    /// borrow gets a fresh, empty-dictionary connection, not the same full one),
243    /// and the frames flushed *earlier* on it are drained / committed best-effort
244    /// on the way out. So the simplest recovery is to return or drop the sender as
245    /// usual and continue on a fresh borrow. If those earlier frames must not be
246    /// lost, drain or commit them *and check* first, as below.
247    ///
248    /// - **Pooled row sender** (`QuestDb::borrow_sender`): a full dictionary marks
249    ///   the connection for retirement, so a plain drop (which *is* the pool
250    ///   return) drains the queue best-effort within `close_flush_timeout` and
251    ///   drops the connection instead of recycling it — the next borrow gets a
252    ///   fresh one. (Nothing extra to call: an explicit `drop_on_return()` does the
253    ///   same and is redundant here.) `wait()` first if the queued frames must not
254    ///   be lost. With `sf_dir` configured they persist in the slot, but so does
255    ///   the dictionary, and the next borrower re-seeds from that slot's side-file
256    ///   at the same size unless the slot drained first — so `wait()` there too, so
257    ///   the slot drains and the next borrower starts clean.
258    /// - **Pooled direct column sender**
259    ///   (`QuestDb::borrow_direct_column_sender`): a full dictionary marks the
260    ///   connection **spent** — retired on return, but its transport is healthy and
261    ///   still drainable — so a plain drop commits the deferred tail best-effort
262    ///   *and* retires the connection. Its `flush` is *deferred* (nothing is
263    ///   committed until [`commit`](crate::db::BorrowedDirectColumnSender::commit)
264    ///   or `flush_and_wait`), so for a *checked* guarantee call `commit(..)` (or
265    ///   `flush_and_wait(..)` on the final chunk) and confirm it succeeded before
266    ///   the drop — `commit` still goes through on a spent connection. Do **not**
267    ///   reach for `drop_on_return()` on a full dictionary: it hard-latches the
268    ///   connection, which makes the drop **skip** the best-effort commit and
269    ///   discard the tail. `reborrow_from_pool()` likewise discards the in-flight
270    ///   tail (its failover contract), so `commit`/`wait()` before it if that tail
271    ///   matters.
272    /// - **Standalone** (`Sender`): call
273    ///   [`close_drain`](crate::ingress::Sender::close_drain) and check it
274    ///   succeeded, then drop and reconnect. Unlike the pooled guards above, a
275    ///   plain drop drains **nothing** here — `SyncProtocolHandler`'s `Drop`
276    ///   shuts down the ILP-over-TCP socket and has no QWP/WebSocket arm at all,
277    ///   so every published-but-unacked frame is discarded with no wait. This is
278    ///   the most lossy of the three flavours on a bare drop, not the least.
279    ///   `close_drain` is bounded by `close_flush_timeout`.
280    /// - **C ABI**: a plain `questdb_db_return_sender` /
281    ///   `questdb_db_return_direct_sender` now retires (does not recycle) a
282    ///   full-dictionary connection and drains / commits its pending frames
283    ///   best-effort — call `qwp_sender_wait` / `qwp_direct_sender_commit` first
284    ///   for a checked guarantee. `questdb_db_drop_direct_sender` force-drops and
285    ///   **skips** the direct sender's tail commit, so on a full dictionary prefer
286    ///   the plain return unless you mean to discard the tail.
287    ///
288    /// **One exception to "that flush loses nothing", and it matters for
289    /// resends.** A chunk too large for a
290    /// single frame is split, and each half is published on its own;
291    /// store-and-forward is at-least-once, so an earlier half can already be
292    /// durably queued when a later half hits the cap. Nothing is lost then
293    /// either, but the operation is no longer known-not-delivered: it is
294    /// reported as delivery-unknown, so **check [`in_doubt`](Error::in_doubt)
295    /// before resending** — a blind resend of the whole chunk duplicates the
296    /// rows the committed prefix already carried.
297    ///
298    /// Distinct from [`InvalidApiCall`](Self::InvalidApiCall) — a caller
299    /// mistake with no recovery — so callers can recognise a full dictionary
300    /// **by code** and take that specific action, without matching on the error
301    /// message text.
302    SymbolDictFull,
303}
304
305/// An error that occurred when using the QuestDB client library.
306///
307/// The payload lives behind a `Box` so `Result<T, Error>` stays pointer-sized
308/// on the happy path: the optional query-side `ServerInfo` and the diagnostic
309/// strings would otherwise push the struct past the `clippy::result_large_err`
310/// threshold.
311#[derive(Debug, PartialEq, Eq, Clone)]
312pub struct Error(Box<ErrorInner>);
313
314#[derive(Debug, PartialEq, Eq, Clone)]
315struct ErrorInner {
316    code: ErrorCode,
317    msg: String,
318    in_doubt: bool,
319    /// Structured QWP/WebSocket sender rejection diagnostic.
320    /// Sender-only.
321    #[cfg(feature = "_sender-qwp-ws")]
322    qwp_ws_rejection: Option<Box<crate::ingress::QwpWsSenderError>>,
323    /// `421 + X-QuestDB-Role` topology reject seen on the QWP/WebSocket
324    /// *sender* upgrade. Sender-only; kept distinct from the query-side
325    /// [`UpgradeReject`](crate::egress::UpgradeReject), which
326    /// carries the richer `SERVER_INFO` role byte.
327    #[cfg(feature = "_sender-qwp-ws")]
328    qwp_ws_role_reject: Option<crate::ingress::QwpWsRoleReject>,
329    /// Server-advertised role + zone from a query-side `421 + X-QuestDB-Role`
330    /// upgrade reject or `SERVER_INFO` target-filter mismatch. Query-only.
331    #[cfg(feature = "_egress")]
332    upgrade_reject: Option<crate::egress::server_event::UpgradeReject>,
333    /// Full last-observed `SERVER_INFO` from a query-side target-filter
334    /// mismatch. Query-only.
335    #[cfg(feature = "_egress")]
336    server_info: Option<crate::egress::server_event::ServerInfo>,
337}
338
339impl Error {
340    /// Create an error with the given code and message.
341    pub fn new<S: Into<String>>(code: ErrorCode, msg: S) -> Error {
342        Error(Box::new(ErrorInner {
343            code,
344            msg: msg.into(),
345            in_doubt: false,
346            #[cfg(feature = "_sender-qwp-ws")]
347            qwp_ws_rejection: None,
348            #[cfg(feature = "_sender-qwp-ws")]
349            qwp_ws_role_reject: None,
350            #[cfg(feature = "_egress")]
351            upgrade_reject: None,
352            #[cfg(feature = "_egress")]
353            server_info: None,
354        }))
355    }
356
357    /// Mark this error as *delivery-unknown* ("in doubt"): the current input's
358    /// bytes may already have reached the server even though the operation
359    /// reported failure (e.g. a socket write that failed mid-frame, or a
360    /// post-publish ACK wait that failed). Surfaced to callers via
361    /// [`Error::in_doubt`]. See `PooledSenderCore::flush` and the `FlushFailure`
362    /// delivery classification.
363    #[must_use]
364    #[cfg(feature = "sync-sender-qwp-ws")]
365    pub(crate) fn with_in_doubt(mut self, in_doubt: bool) -> Self {
366        self.0.in_doubt = in_doubt;
367        self
368    }
369
370    /// `true` when the operation that produced this error is *delivery-unknown*
371    /// ("in doubt"): the current input may already have reached the server, so
372    /// blindly replaying it on a fresh connection can duplicate rows.
373    ///
374    /// This is independent of the [`code`](Error::code): a delivery-unknown
375    /// failure typically reports [`ErrorCode::FailoverRetry`] (the connection
376    /// can be replaced), yet `FailoverRetry` alone does **not** mean the input
377    /// is safe to retry. Use `in_doubt() == false` together with a retryable
378    /// code to decide whether re-sending the same input is safe; when
379    /// `in_doubt()` is `true`, only replay if table-level dedup/upsert keys
380    /// make duplicates harmless.
381    #[must_use]
382    pub fn in_doubt(&self) -> bool {
383        self.0.in_doubt
384    }
385
386    /// Attach a structured QWP/WebSocket rejection to this error.
387    #[cfg(feature = "_sender-qwp-ws")]
388    pub fn with_qwp_ws_rejection(mut self, rejection: crate::ingress::QwpWsSenderError) -> Self {
389        self.0.qwp_ws_rejection = Some(Box::new(rejection));
390        self
391    }
392
393    #[cfg(feature = "_sender-qwp-ws")]
394    pub(crate) fn with_qwp_ws_role_reject(
395        mut self,
396        role_reject: crate::ingress::QwpWsRoleReject,
397    ) -> Self {
398        self.0.qwp_ws_role_reject = Some(role_reject);
399        self
400    }
401
402    /// Builder: attach a query-side [`UpgradeReject`](crate::egress::UpgradeReject)
403    /// (HTTP `421 + X-QuestDB-Role` or `SERVER_INFO` target mismatch) so the
404    /// host-health tracker can read the role + zone without re-parsing.
405    #[cfg(feature = "_egress")]
406    pub fn with_upgrade_reject(
407        mut self,
408        reject: crate::egress::server_event::UpgradeReject,
409    ) -> Self {
410        self.0.upgrade_reject = Some(reject);
411        self
412    }
413
414    /// Builder: attach the full last-observed `SERVER_INFO` to a
415    /// `RoleMismatch` produced from the `SERVER_INFO` target-mismatch path.
416    #[cfg(feature = "_egress")]
417    pub fn with_server_info(mut self, info: crate::egress::server_event::ServerInfo) -> Self {
418        self.0.server_info = Some(info);
419        self
420    }
421
422    #[cfg(feature = "sync-sender-http")]
423    pub(crate) fn from_ureq_error(err: ureq::Error, url: &str) -> Error {
424        match err {
425            ureq::Error::StatusCode(code) => {
426                if code == 404 {
427                    fmt!(
428                        HttpNotSupported,
429                        "Could not flush buffer: HTTP endpoint does not support ILP."
430                    )
431                } else if [401, 403].contains(&code) {
432                    fmt!(
433                        AuthError,
434                        "Could not flush buffer: HTTP endpoint authentication error [code: {}]",
435                        code
436                    )
437                } else {
438                    fmt!(SocketError, "Could not flush buffer: {}: {}", url, err)
439                }
440            }
441            e => {
442                fmt!(SocketError, "Could not flush buffer: {}: {}", url, e)
443            }
444        }
445    }
446
447    /// Get the error code (category) of this error.
448    pub fn code(&self) -> ErrorCode {
449        self.0.code
450    }
451
452    /// Get the string message of this error.
453    pub fn msg(&self) -> &str {
454        &self.0.msg
455    }
456
457    /// Return the structured QWP/WebSocket rejection that made this error
458    /// terminal, if one is available.
459    #[cfg(feature = "_sender-qwp-ws")]
460    pub fn qwp_ws_rejection(&self) -> Option<&crate::ingress::QwpWsSenderError> {
461        self.0.qwp_ws_rejection.as_deref()
462    }
463
464    #[cfg(feature = "_sender-qwp-ws")]
465    pub(crate) fn qwp_ws_role_reject(&self) -> Option<&crate::ingress::QwpWsRoleReject> {
466        self.0.qwp_ws_role_reject.as_ref()
467    }
468
469    /// Server-advertised role + zone carried alongside a query-side error.
470    /// `Some` when the error originated from an HTTP `421 + X-QuestDB-Role`
471    /// upgrade reject or a `SERVER_INFO` role / `target=` filter mismatch;
472    /// `None` for all other failure paths.
473    #[cfg(feature = "_egress")]
474    pub fn upgrade_reject(&self) -> Option<&crate::egress::server_event::UpgradeReject> {
475        self.0.upgrade_reject.as_ref()
476    }
477
478    /// Full last-observed `SERVER_INFO` carried alongside this error. `Some`
479    /// only when the rejection came from the `SERVER_INFO` target-mismatch
480    /// path; `None` everywhere else. Lets callers distinguish "no endpoint
481    /// matched `target=`" (this is `Some`) from "all endpoints unreachable"
482    /// (this is `None`).
483    #[cfg(feature = "_egress")]
484    pub fn server_info(&self) -> Option<&crate::egress::server_event::ServerInfo> {
485        self.0.server_info.as_ref()
486    }
487}
488
489impl Display for Error {
490    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
491        f.write_str(&self.0.msg)
492    }
493}
494
495impl std::error::Error for Error {}
496
497impl From<Infallible> for Error {
498    fn from(_: Infallible) -> Self {
499        unreachable!()
500    }
501}
502
503/// A specialized `Result` type for the crate's [`Error`] type.
504pub type Result<T> = std::result::Result<T, Error>;
505
506pub(crate) use fmt;
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn errors_are_not_in_doubt_by_default() {
514        let err = Error::new(ErrorCode::SocketError, "boom");
515        assert!(!err.in_doubt());
516    }
517
518    #[test]
519    #[cfg(feature = "sync-sender-qwp-ws")]
520    fn with_in_doubt_sets_and_preserves_code_and_msg() {
521        let err =
522            Error::new(ErrorCode::FailoverRetry, "mid-frame write failed").with_in_doubt(true);
523        assert!(err.in_doubt());
524        assert_eq!(err.code(), ErrorCode::FailoverRetry);
525        assert_eq!(err.msg(), "mid-frame write failed");
526        // The flag is a flat boolean, not a one-way latch.
527        assert!(!err.with_in_doubt(false).in_doubt());
528    }
529
530    #[test]
531    fn display_matches_msg() {
532        let err = Error::new(ErrorCode::ProtocolError, "boom");
533        assert_eq!(format!("{}", err), "boom");
534    }
535
536    #[test]
537    fn fmt_macro_builds_error() {
538        let err = fmt!(ProtocolError, "bad code 0x{:02X}", 0xAB);
539        assert_eq!(err.code(), ErrorCode::ProtocolError);
540        assert_eq!(err.msg(), "bad code 0xAB");
541    }
542
543    #[cfg(feature = "_egress")]
544    #[test]
545    fn server_info_and_upgrade_reject_round_trip() {
546        use crate::egress::server_event::{ServerInfo, ServerRole, UpgradeReject};
547        let err_plain = Error::new(ErrorCode::SocketError, "x");
548        assert!(err_plain.server_info().is_none());
549        assert!(err_plain.upgrade_reject().is_none());
550
551        let info = ServerInfo {
552            role: ServerRole::Replica,
553            epoch: 7,
554            capabilities: 0,
555            server_wall_ns: 1_700_000_000_000_000_000,
556            cluster_id: "c-1".into(),
557            node_id: "n-2".into(),
558            zone_id: Some("eu-west-1a".into()),
559        };
560        let reject = UpgradeReject::new(0x02, "REPLICA", Some("eu-west-1a".into()));
561        let err = Error::new(ErrorCode::RoleMismatch, "no match")
562            .with_server_info(info.clone())
563            .with_upgrade_reject(reject.clone());
564        assert_eq!(err.server_info(), Some(&info));
565        assert_eq!(err.upgrade_reject(), Some(&reject));
566    }
567
568    #[test]
569    fn error_code_is_exhaustively_known() {
570        // Compile-time tripwire. This match is WILDCARD-FREE, which the
571        // *defining* crate is allowed to write over its own `#[non_exhaustive]`
572        // enum (the attribute only forces a `_` arm in downstream crates).
573        // Adding a new `ErrorCode` variant breaks THIS compile — a forcing
574        // reminder to also map it in the FFI `impl From<ErrorCode> for
575        // line_sender_error_code` (questdb-rs-ffi/src/lib.rs) and the C/C++
576        // headers, none of which the compiler can check cross-crate.
577        fn _exhaustive(code: ErrorCode) {
578            match code {
579                ErrorCode::CouldNotResolveAddr => {}
580                ErrorCode::InvalidApiCall => {}
581                ErrorCode::SocketError => {}
582                ErrorCode::ConnectTimeout => {}
583                ErrorCode::InvalidUtf8 => {}
584                ErrorCode::InvalidName => {}
585                ErrorCode::InvalidTimestamp => {}
586                ErrorCode::AuthError => {}
587                ErrorCode::TlsError => {}
588                ErrorCode::HttpNotSupported => {}
589                ErrorCode::ServerFlushError => {}
590                ErrorCode::ConfigError => {}
591                ErrorCode::ArrayError => {}
592                ErrorCode::ProtocolVersionError => {}
593                ErrorCode::InvalidDecimal => {}
594                ErrorCode::ServerRejection => {}
595                ErrorCode::ArrowUnsupportedColumnKind => {}
596                ErrorCode::ArrowIngest => {}
597                ErrorCode::FailoverRetry => {}
598                ErrorCode::RoleMismatch => {}
599                ErrorCode::HandshakeError => {}
600                ErrorCode::UnsupportedServer => {}
601                ErrorCode::ProtocolError => {}
602                ErrorCode::InvalidBind => {}
603                ErrorCode::ServerSchemaMismatch => {}
604                ErrorCode::ServerParseError => {}
605                ErrorCode::ServerInternalError => {}
606                ErrorCode::ServerSecurityError => {}
607                ErrorCode::LimitExceeded => {}
608                ErrorCode::ServerLimitExceeded => {}
609                ErrorCode::Cancelled => {}
610                ErrorCode::FailoverWouldDuplicate => {}
611                ErrorCode::SchemaDrift => {}
612                ErrorCode::NoSchema => {}
613                ErrorCode::ArrowExport => {}
614                ErrorCode::BatchTooLarge => {}
615                ErrorCode::StoreResendRequired => {}
616                ErrorCode::SymbolDictFull => {}
617            }
618        }
619        let _ = _exhaustive;
620    }
621}