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}