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 `DecodedValue::Decimal`'s string form wasn't a valid decimal.
58    #[error(transparent)]
59    Decimal(#[from] rust_decimal::Error),
60    /// A result column came back with a type OID this decoder has no rule
61    /// for and that connect-time discovery didn't classify either. Returned
62    /// rather than guessed at: the fallback that used to stand in here
63    /// (decode the raw binary as UTF-8 text) is only correct for text-like
64    /// types, and silently produced mojibake for everything else.
65    #[error(
66        "no decoder for PostgreSQL type OID {oid} — if this type was created after the \
67         connection opened, the type registry needs refreshing"
68    )]
69    UnknownTypeOid { oid: u32 },
70    /// Everything else: DSN parse failures, the "cannot bind a composite
71    /// as a query parameter" case — none of these originate from a real
72    /// Postgres response, so none of them carry a SQLSTATE.
73    #[error("{0}")]
74    Other(Box<dyn std::error::Error + Send + Sync>),
75}
76
77// `postgres_types::{FromSql, ToSql}` (used directly for `rust_decimal`
78// numeric decode/encode) are trait-mandated to return this exact boxed
79// type — a direct `From` lets those sites keep using plain `?`.
80impl From<Box<dyn std::error::Error + Send + Sync>> for Error {
81    fn from(e: Box<dyn std::error::Error + Send + Sync>) -> Self {
82        Error::Other(e)
83    }
84}
85
86/// What the server actually said, for a `tokio_postgres::Error` — falling
87/// back to its own `Display` for a client-side failure (connection reset,
88/// encoding, ...) which has no `DbError` behind it.
89fn db_error_message(err: &tokio_postgres::Error) -> String {
90    match err.as_db_error() {
91        Some(db) => db.message().to_string(),
92        None => err.to_string(),
93    }
94}
95
96/// Same, for a pool error whose backend failure is a `tokio_postgres::Error`.
97fn pool_error_message(err: &deadpool_postgres::PoolError) -> String {
98    match err {
99        deadpool_postgres::PoolError::Backend(e) => db_error_message(e),
100        other => other.to_string(),
101    }
102}
103
104impl Error {
105    /// The Postgres SQLSTATE code, when this error is a real response from
106    /// the server (as opposed to a connection/pool/decode failure, none of
107    /// which have one) — the single source of truth error mapping at the
108    /// pyo3 boundary classifies on.
109    pub fn sqlstate(&self) -> Option<&SqlState> {
110        match self {
111            Error::Postgres(e) => e.code(),
112            Error::Pool(deadpool_postgres::PoolError::Backend(e)) => e.code(),
113            _ => None,
114        }
115    }
116
117    pub(crate) fn message(msg: impl Into<String>) -> Self {
118        Error::Other(msg.into().into())
119    }
120
121    /// The structured `DbError` a real server response carries — `None`
122    /// for a connection/pool/decode failure, none of which have one.
123    fn as_db_error(&self) -> Option<&tokio_postgres::error::DbError> {
124        match self {
125            Error::Postgres(e) => e.as_db_error(),
126            Error::Pool(deadpool_postgres::PoolError::Backend(e)) => e.as_db_error(),
127            _ => None,
128        }
129    }
130
131    /// The message a caller should actually show. `tokio_postgres::Error`'s
132    /// own `Display` only renders a generic category string for a
133    /// server-side error (`"db error"` for every `DbError`-backed failure,
134    /// regardless of what the server actually said) — the real detail
135    /// (e.g. `duplicate key value violates unique constraint "..."`) lives
136    /// one level deeper, in the `DbError` its `source()` wraps, so this
137    /// prefers that when present and falls back to `Display` otherwise.
138    pub fn pg_message(&self) -> String {
139        match self.as_db_error() {
140            Some(db) => db.message().to_string(),
141            None => self.to_string(),
142        }
143    }
144
145    /// `(schema, type name)` for the PostgreSQL scalar/domain a
146    /// `CHECK_VIOLATION` failed against (Postgres's `SchemaName`/
147    /// `DataTypeName` error fields — both populated for a domain check,
148    /// confirmed live) — `Some(("public", "Email"))` for a registered
149    /// custom scalar's own DOMAIN check; `None` for an ordinary
150    /// table-level CHECK (use `violated_table` instead).
151    pub fn violated_scalar(&self) -> Option<(&str, &str)> {
152        let db = self.as_db_error()?;
153        Some((db.schema()?, db.datatype()?))
154    }
155
156    /// `(schema, table)` a `CHECK_VIOLATION`'s table-level constraint
157    /// belongs to, when Postgres reports one (a domain-level check
158    /// reports `violated_datatype` instead, not this).
159    pub fn violated_table(&self) -> Option<(&str, &str)> {
160        let db = self.as_db_error()?;
161        Some((db.schema()?, db.table()?))
162    }
163}