turnframe_store_postgres/error.rs
1//! Translating PostgreSQL failures into the closed error surface of the
2//! persistence contract.
3//!
4//! A store speaks [`StoreError`] and nothing else, so the runtime can classify a
5//! persistence failure without knowing which database is underneath. The
6//! translation below is the whole of that promise for this adapter, and the
7//! distinction that matters most is between *nothing was written* and *the write
8//! may have landed*:
9//!
10//! | PostgreSQL failure | [`StoreError`] | Why |
11//! |---|---|---|
12//! | unique or exclusion violation | `Conflict` | a uniqueness rule refused the row; nothing was written |
13//! | foreign key violation | `Conflict` | a referenced row was missing or in use; nothing was written |
14//! | check violation | `Other(INVALID_RECORD)` | the row itself is illegal, e.g. a negative revision |
15//! | serialization failure, deadlock | `Conflict` | the transaction lost a race and rolled back whole |
16//! | `statement_timeout` cancellation | `Timeout` | the statement was cut off mid-flight |
17//! | connection exception, too many connections | `Unavailable` | the statement never ran |
18//! | any other SQLSTATE | `Other("turnframe.store.postgres.<sqlstate>")` | a stable, loggable code |
19//! | a value that will not decode | `Corrupt` | stored data disagrees with the schema this adapter relies on |
20//!
21//! Every write this adapter makes runs inside a transaction, so a connection
22//! that dies mid-statement rolls the transaction back and nothing survives —
23//! which is why a transport error maps to `Unavailable` rather than `Timeout`.
24//! The single exception is the `COMMIT` itself: a failure there is genuinely
25//! indeterminate, and [`commit_failed`] maps it to `Timeout` so the caller
26//! re-reads instead of retrying (spec §16.5).
27//!
28//! No message from the database is ever carried into a [`StoreError`]: a
29//! constraint name or a server message can quote the row that failed, and rows
30//! hold user data. Only SQLSTATE codes cross the boundary.
31
32use sqlx::error::ErrorKind;
33use turnframe_store::error::{StoreError, invalid_record};
34
35/// SQLSTATE class 08: the connection could not be established or was lost
36/// before the statement ran.
37const CLASS_CONNECTION_EXCEPTION: &str = "08";
38/// SQLSTATE class 53: the server ran out of a resource, typically connections.
39const CLASS_INSUFFICIENT_RESOURCES: &str = "53";
40/// `query_canceled`, which is what `statement_timeout` raises.
41const QUERY_CANCELED: &str = "57014";
42/// `admin_shutdown`, raised when the server terminates the backend.
43const ADMIN_SHUTDOWN: &str = "57P01";
44/// `serialization_failure`.
45const SERIALIZATION_FAILURE: &str = "40001";
46/// `deadlock_detected`.
47const DEADLOCK_DETECTED: &str = "40P01";
48
49/// Prefix of the stable codes this adapter reports for an unclassified
50/// PostgreSQL error, completed by the five-character SQLSTATE.
51pub const POSTGRES_CODE_PREFIX: &str = "turnframe.store.postgres.";
52
53/// Maps a `sqlx` failure onto the contract's error surface.
54///
55/// See the module documentation for the table and its reasoning.
56#[must_use]
57pub fn store_error(error: &sqlx::Error) -> StoreError {
58 match error {
59 sqlx::Error::RowNotFound => StoreError::NotFound,
60 sqlx::Error::Database(database) => database_error(database.as_ref()),
61 // The pool never handed out a connection, so no statement ran.
62 sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed => StoreError::Unavailable,
63 // Transport and driver failures. Every write is inside a transaction, so
64 // a connection lost here takes the transaction down with it.
65 sqlx::Error::Io(_)
66 | sqlx::Error::Tls(_)
67 | sqlx::Error::Protocol(_)
68 | sqlx::Error::WorkerCrashed => StoreError::Unavailable,
69 // A value came back that this adapter cannot read: the schema and the
70 // code disagree, which is exactly what `Corrupt` is for.
71 sqlx::Error::ColumnDecode { .. }
72 | sqlx::Error::Decode(_)
73 | sqlx::Error::ColumnNotFound(_)
74 | sqlx::Error::ColumnIndexOutOfBounds { .. }
75 | sqlx::Error::TypeNotFound { .. } => StoreError::Corrupt,
76 sqlx::Error::Encode(_) => StoreError::Serialization,
77 _ => StoreError::Other {
78 code: format!("{POSTGRES_CODE_PREFIX}driver"),
79 },
80 }
81}
82
83/// Maps the failure of a `COMMIT` statement.
84///
85/// A commit that does not answer is the one place where this adapter cannot say
86/// whether the write landed, so it reports [`StoreError::Timeout`]: the caller
87/// must re-read rather than retry (spec §16.5). A commit refused by the server
88/// with a SQLSTATE — a deferred constraint, a serialization failure — did roll
89/// back and keeps its normal classification.
90#[must_use]
91pub fn commit_failed(error: &sqlx::Error) -> StoreError {
92 match error {
93 sqlx::Error::Database(_) => store_error(error),
94 _ => StoreError::Timeout,
95 }
96}
97
98/// Classifies an error the server itself returned.
99fn database_error(database: &dyn sqlx::error::DatabaseError) -> StoreError {
100 match database.kind() {
101 ErrorKind::UniqueViolation | ErrorKind::ForeignKeyViolation => return StoreError::Conflict,
102 // A row that violates a CHECK or a NOT NULL is an illegal record, not a
103 // race: the caller sent something this schema refuses to hold.
104 ErrorKind::CheckViolation | ErrorKind::NotNullViolation => return invalid_record(),
105 _ => {}
106 }
107 let Some(sqlstate) = database.code() else {
108 return StoreError::Other {
109 code: format!("{POSTGRES_CODE_PREFIX}unknown"),
110 };
111 };
112 by_sqlstate(sqlstate.as_ref())
113}
114
115/// Classifies a SQLSTATE the `sqlx` error kinds do not cover.
116fn by_sqlstate(sqlstate: &str) -> StoreError {
117 match sqlstate {
118 SERIALIZATION_FAILURE | DEADLOCK_DETECTED => StoreError::Conflict,
119 QUERY_CANCELED => StoreError::Timeout,
120 ADMIN_SHUTDOWN => StoreError::Unavailable,
121 _ if sqlstate.starts_with(CLASS_CONNECTION_EXCEPTION)
122 || sqlstate.starts_with(CLASS_INSUFFICIENT_RESOURCES) =>
123 {
124 StoreError::Unavailable
125 }
126 // Stable, loggable, and carries no server message.
127 _ => StoreError::Other {
128 code: format!("{POSTGRES_CODE_PREFIX}{sqlstate}"),
129 },
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn transport_failures_report_that_nothing_was_written() {
139 let io = sqlx::Error::Io(std::io::Error::other("boom"));
140 assert_eq!(store_error(&io), StoreError::Unavailable);
141 assert_eq!(
142 store_error(&sqlx::Error::PoolTimedOut),
143 StoreError::Unavailable
144 );
145 assert_eq!(
146 store_error(&sqlx::Error::PoolClosed),
147 StoreError::Unavailable
148 );
149 }
150
151 #[test]
152 fn a_commit_that_does_not_answer_is_indeterminate() {
153 // The write may or may not have landed, so the caller must re-read.
154 let io = sqlx::Error::Io(std::io::Error::other("boom"));
155 assert_eq!(commit_failed(&io), StoreError::Timeout);
156 }
157
158 #[test]
159 fn undecodable_values_are_corrupt_not_conflicts() {
160 let decode = sqlx::Error::ColumnDecode {
161 index: "status".to_owned(),
162 source: "not a status".into(),
163 };
164 assert_eq!(store_error(&decode), StoreError::Corrupt);
165 assert_eq!(
166 store_error(&sqlx::Error::ColumnNotFound("status".to_owned())),
167 StoreError::Corrupt
168 );
169 }
170
171 #[test]
172 fn sqlstates_map_to_the_contract() {
173 assert_eq!(by_sqlstate(SERIALIZATION_FAILURE), StoreError::Conflict);
174 assert_eq!(by_sqlstate(DEADLOCK_DETECTED), StoreError::Conflict);
175 assert_eq!(by_sqlstate(QUERY_CANCELED), StoreError::Timeout);
176 assert_eq!(by_sqlstate("08006"), StoreError::Unavailable);
177 assert_eq!(by_sqlstate("53300"), StoreError::Unavailable);
178 assert_eq!(
179 by_sqlstate("22012"),
180 StoreError::Other {
181 code: "turnframe.store.postgres.22012".to_owned()
182 }
183 );
184 }
185
186 #[test]
187 fn no_server_message_reaches_the_error_surface() {
188 // Rows hold user data and a server message quotes rows, so only the
189 // SQLSTATE may cross.
190 let rendered = by_sqlstate("22012").to_string();
191 assert_eq!(rendered, "store failure turnframe.store.postgres.22012");
192 }
193}