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}