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}