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}