Skip to main content

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}