Skip to main content

pylon_pgcon/
error.rs

1//
2// This source file is part of the Pylon open source project.
3//
4// Copyright (c) 2026 Jaldis B.V.
5//
6// Licensed under the MIT OR Apache-2.0 license (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10//     https://opensource.org/licenses/MIT
11//     https://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing, software
14// distributed under the License is distributed on an "AS IS" BASIS,
15// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16// See the License for the specific language governing permissions and
17// limitations under the License.
18//
19
20//! The crate's single error type — deliberately *not* a boxed
21//! `dyn std::error::Error`, unlike the rest of this crate's early phases.
22//! Error mapping to Pylon's Python exception hierarchy (`pylon.exceptions`)
23//! needs the Postgres SQLSTATE code (`"40001"` serialization failure,
24//! `"40P01"` deadlock detected) to pick the right class, and the Python-side
25//! `_fmt_pg_error` needs it to format the message. Erasing into a boxed
26//! `dyn Error` at every `?` site — this crate's original design — throws
27//! that code away before it can ever reach the pyo3 boundary.
28
29use tokio_postgres::error::SqlState;
30
31pub type Result<T> = std::result::Result<T, Error>;
32
33#[derive(Debug, thiserror::Error)]
34pub enum Error {
35    // Not `transparent`: `tokio_postgres::Error`'s own `Display` renders
36    // every server-side failure as the bare string `"db error"`, with what
37    // the server actually said reachable only through `source()`. Anything
38    // that logged one — `IndexWorker(Vector): claim_batch failed: db error`
39    // — therefore threw away the only useful part. See `pg_message`.
40    #[error("{}", db_error_message(.0))]
41    Postgres(#[from] tokio_postgres::Error),
42    #[error("{}", pool_error_message(.0))]
43    Pool(#[from] deadpool_postgres::PoolError),
44    #[error(transparent)]
45    Build(#[from] deadpool_postgres::BuildError),
46    /// A wire-format buffer was the wrong length for the type being
47    /// decoded (malformed or truncated data).
48    #[error(transparent)]
49    WireLength(#[from] std::array::TryFromSliceError),
50    /// A `text`/`varchar`/`bpchar` field's bytes weren't valid UTF-8.
51    #[error(transparent)]
52    Utf8(#[from] std::str::Utf8Error),
53    /// A jsonb field's bytes weren't valid JSON, or a `DecodedValue::Object`
54    /// being bound as a jsonb parameter failed to serialize.
55    #[error(transparent)]
56    Json(#[from] serde_json::Error),
57    /// A result column came back with a type OID this decoder has no rule
58    /// for and that connect-time discovery didn't classify either. Returned
59    /// rather than guessed at: the fallback that used to stand in here
60    /// (decode the raw binary as UTF-8 text) is only correct for text-like
61    /// types, and silently produced mojibake for everything else.
62    #[error(
63        "no decoder for PostgreSQL type OID {oid} — if this type was created after the \
64         connection opened, the type registry needs refreshing"
65    )]
66    UnknownTypeOid { oid: u32 },
67    /// Everything else: DSN parse failures, the "cannot bind a composite
68    /// as a query parameter" case — none of these originate from a real
69    /// Postgres response, so none of them carry a SQLSTATE.
70    #[error("{0}")]
71    Other(Box<dyn std::error::Error + Send + Sync>),
72}
73
74// `postgres_types::{FromSql, ToSql}` are trait-mandated to return this exact
75// boxed type — a direct `From` lets those sites keep using plain `?`.
76impl From<Box<dyn std::error::Error + Send + Sync>> for Error {
77    fn from(e: Box<dyn std::error::Error + Send + Sync>) -> Self {
78        Error::Other(e)
79    }
80}
81
82/// What the server actually said, for a `tokio_postgres::Error` — falling
83/// back to its own `Display` for a client-side failure (connection reset,
84/// encoding, ...) which has no `DbError` behind it.
85fn db_error_message(err: &tokio_postgres::Error) -> String {
86    if let Some(db) = err.as_db_error() {
87        return db.message().to_string();
88    }
89    // A client-side failure's own `Display` is only a summary -- "error
90    // serializing parameter 0" -- and what actually went wrong sits in its
91    // source. Walk the chain so a parameter the encoder refused reports the
92    // type it could not be bound as, rather than just its position.
93    let mut message = err.to_string();
94    let mut cause = std::error::Error::source(err);
95    while let Some(next) = cause {
96        let reason = next.to_string();
97        if !message.contains(&reason) {
98            message.push_str(": ");
99            message.push_str(&reason);
100        }
101        cause = next.source();
102    }
103    message
104}
105
106/// Same, for a pool error whose backend failure is a `tokio_postgres::Error`.
107fn pool_error_message(err: &deadpool_postgres::PoolError) -> String {
108    match err {
109        deadpool_postgres::PoolError::Backend(e) => db_error_message(e),
110        other => other.to_string(),
111    }
112}
113
114impl Error {
115    /// The Postgres SQLSTATE code, when this error is a real response from
116    /// the server (as opposed to a connection/pool/decode failure, none of
117    /// which have one) — the single source of truth error mapping at the
118    /// pyo3 boundary classifies on.
119    pub fn sqlstate(&self) -> Option<&SqlState> {
120        match self {
121            Error::Postgres(e) => e.code(),
122            Error::Pool(deadpool_postgres::PoolError::Backend(e)) => e.code(),
123            _ => None,
124        }
125    }
126
127    pub(crate) fn message(msg: impl Into<String>) -> Self {
128        Error::Other(msg.into().into())
129    }
130
131    /// The structured `DbError` a real server response carries — `None`
132    /// for a connection/pool/decode failure, none of which have one.
133    fn as_db_error(&self) -> Option<&tokio_postgres::error::DbError> {
134        match self {
135            Error::Postgres(e) => e.as_db_error(),
136            Error::Pool(deadpool_postgres::PoolError::Backend(e)) => e.as_db_error(),
137            _ => None,
138        }
139    }
140
141    /// The message a caller should actually show. `tokio_postgres::Error`'s
142    /// own `Display` only renders a generic category string for a
143    /// server-side error (`"db error"` for every `DbError`-backed failure,
144    /// regardless of what the server actually said) — the real detail
145    /// (e.g. `duplicate key value violates unique constraint "..."`) lives
146    /// one level deeper, in the `DbError` its `source()` wraps, so this
147    /// prefers that when present and falls back to `Display` otherwise.
148    pub fn pg_message(&self) -> String {
149        match self.as_db_error() {
150            Some(db) => db.message().to_string(),
151            None => self.to_string(),
152        }
153    }
154
155    /// `(schema, type name)` for the PostgreSQL scalar/domain a
156    /// `CHECK_VIOLATION` failed against (Postgres's `SchemaName`/
157    /// `DataTypeName` error fields — both populated for a domain check,
158    /// confirmed live) — `Some(("public", "Email"))` for a registered
159    /// custom scalar's own DOMAIN check; `None` for an ordinary
160    /// table-level CHECK (use `violated_table` instead).
161    pub fn violated_scalar(&self) -> Option<(&str, &str)> {
162        let db = self.as_db_error()?;
163        Some((db.schema()?, db.datatype()?))
164    }
165
166    /// `(schema, table)` a `CHECK_VIOLATION`'s table-level constraint
167    /// belongs to, when Postgres reports one (a domain-level check
168    /// reports `violated_datatype` instead, not this).
169    pub fn violated_table(&self) -> Option<(&str, &str)> {
170        let db = self.as_db_error()?;
171        Some((db.schema()?, db.table()?))
172    }
173}