Skip to main content

pylon_client/
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. Follows `pylon_pgcon::Error`'s own
21//! convention (a real enum, not a boxed `dyn Error`) for the same reason:
22//! the transaction retry loop (`transaction.rs`) needs the Postgres SQLSTATE
23//! to tell a retriable serialization failure/deadlock apart from anything
24//! else, and erasing to `dyn Error` at every `?` site would throw that away.
25
26use tokio_postgres::error::SqlState;
27
28pub type Result<T> = std::result::Result<T, Error>;
29
30#[derive(Debug, thiserror::Error)]
31pub enum Error {
32    /// A connection/pool/decode/server-response failure from the driver.
33    #[error(transparent)]
34    Db(#[from] pylon_pgcon::Error),
35    /// PyQL failed to compile — a syntax/type/resolution/cardinality error.
36    #[error(transparent)]
37    Compile(#[from] pylon_core::error::PyQLError),
38    /// A row didn't fit the type `query::<R, _>` was asked to decode it
39    /// into — a shape missing a field the struct declares, an enum label
40    /// with no matching variant, an integer too wide for its Rust type.
41    #[error(transparent)]
42    Decode(#[from] crate::queryable::DecodeError),
43    /// A required query parameter (named or `__global__`-prefixed) had no
44    /// matching entry in the params/globals passed by the caller.
45    #[error("missing query parameter: {0}")]
46    MissingParam(String),
47    /// An argument the query cannot mean — today only a NULL inside an array,
48    /// which PyQL has no type for. Carries the wording verbatim so every
49    /// client reports it the same way.
50    #[error("{0}")]
51    InvalidArgument(String),
52    /// `query_single`/`query_required_single` (and their `_json` siblings)
53    /// got more than one row back.
54    #[error("expected at most one result, got {got}")]
55    ResultCardinality { got: usize },
56    /// `query_required_single` (and its `_json` sibling) got zero rows.
57    #[error("expected exactly one result, got none")]
58    NoData,
59    /// Neither `pylon migration apply` nor `pylon migration watch` has ever
60    /// run against this database, so there's no schema snapshot to load —
61    /// a bare schema-file edit has no effect until one of those does.
62    #[error(
63        "no schema snapshot found in the database — run `pylon migration create` and `pylon migration apply` first"
64    )]
65    NoSchemaSnapshot,
66    #[error("failed to parse schema JSON: {0}")]
67    SchemaJson(#[from] serde_json::Error),
68    /// `EXPLAIN`'s raw JSON output failed to correlate against the query's
69    /// own `analyze_paths` (`pylon_core::analyze::build_coarse_grained`).
70    #[error("failed to build analyze tree: {0}")]
71    Analyze(String),
72    /// A `pylon_cache::Cache` open/get/put failure — that crate's own
73    /// errors are a boxed `dyn Error + Send + Sync`, not a good match for
74    /// thiserror's `#[from]`/`transparent`, so this just carries the
75    /// rendered message.
76    #[error("cache error: {0}")]
77    Cache(String),
78    /// `Client::listen(name)` — no `Channel` declared in the schema matches
79    /// *name* (bare or `module::name`).
80    #[error("'{0}' is not a known Channel")]
81    UnknownChannel(String),
82    /// A `Client::listen()` NOTIFY payload didn't match its Channel's own
83    /// declared shape (bad JSON, a value that doesn't parse as its
84    /// declared scalar type, a missing Object field, ...).
85    #[error("payload doesn't match its declared Channel shape: {0}")]
86    MalformedPayload(String),
87    /// Not a failure: a transaction body asking to be rolled back instead
88    /// of committed. The Rust counterpart of `pylon.Rollback` — a body that
89    /// wants to write, read its own writes and then leave nothing behind
90    /// (a test, a dry run) returns this. It rides in `Error` because the
91    /// body's return type is `Result<T>` and there is no `T` to hand back
92    /// on a path that deliberately produced nothing; [`Client::transaction_opt`]
93    /// turns it into `Ok(None)` for callers who would rather not see an
94    /// error at all.
95    #[error("transaction rolled back at the request of its body")]
96    Rollback,
97}
98
99impl Error {
100    /// The Postgres SQLSTATE code, when this wraps a real server response —
101    /// `None` for a compile error or a connection/pool/decode failure.
102    pub fn sqlstate(&self) -> Option<&SqlState> {
103        match self {
104            Error::Db(e) => e.sqlstate(),
105            _ => None,
106        }
107    }
108
109    /// `40001` — a serializable/repeatable-read transaction lost a write
110    /// skew race. Retriable by re-running the whole transaction body.
111    pub fn is_serialization_error(&self) -> bool {
112        self.sqlstate() == Some(&SqlState::T_R_SERIALIZATION_FAILURE)
113    }
114
115    /// `40P01` — Postgres broke a deadlock by aborting this transaction.
116    /// Retriable the same way a serialization failure is.
117    pub fn is_deadlock(&self) -> bool {
118        self.sqlstate() == Some(&SqlState::T_R_DEADLOCK_DETECTED)
119    }
120
121    /// Either of the two conditions the retrying transaction loop
122    /// (`transaction.rs`) automatically retries on.
123    pub fn is_retriable(&self) -> bool {
124        self.is_serialization_error() || self.is_deadlock()
125    }
126
127    /// A deliberate abort ([`Error::Rollback`]) rather than a failure —
128    /// worth distinguishing when a caller drives
129    /// [`Client::transaction_with_attempts`](crate::Client::transaction_with_attempts)
130    /// directly instead of going through
131    /// [`Client::transaction_opt`](crate::Client::transaction_opt).
132    pub fn is_rollback(&self) -> bool {
133        matches!(self, Error::Rollback)
134    }
135}