ff_backend_postgres/error.rs
1//! `sqlx::Error` → [`EngineError`] mapping for the Postgres backend.
2//!
3//! **RFC-v0.7 Wave 0.** This is a map-sketch: enough surface for
4//! subsequent waves' trait-method bodies to route transport faults
5//! through `EngineError::Transport { backend: "postgres", .. }` plus
6//! the two typed cases the design-question matrix already pinned:
7//!
8//! * `SqlState::UniqueViolation` (`23505`) → [`EngineError::Conflict`]
9//! — mirrors the Valkey backend's conflict classification for
10//! duplicate-key writes (e.g. waitpoint id collision, unique
11//! index violation on `ff_completion_event` etc.).
12//! * `SqlState::SerializationFailure` (`40001`) / `DeadlockDetected`
13//! (`40P01`) → [`EngineError::Contention`] — per Q11 (isolation
14//! level default) these are the retryable serialization faults
15//! SERIALIZABLE can raise; callers retry per RFC-010 §10.7.
16//!
17//! Every other `sqlx::Error` boxes through `Transport` as a safe
18//! default. Future waves refine (e.g. `RowNotFound` → `NotFound`,
19//! connection-pool-closed → `Unavailable`).
20
21use ff_core::engine_error::{ConflictKind, ContentionKind, EngineError};
22
23/// Convert a `sqlx::Error` into an [`EngineError`].
24///
25/// Kept as a free function (rather than a `From` impl) so call sites
26/// can thread extra context (method label, key, etc.) through
27/// [`ff_core::engine_error::backend_context`] alongside. A blanket
28/// `From<sqlx::Error> for EngineError` is also provided below for
29/// ergonomics in `?` chains where no extra context is needed.
30pub fn map_sqlx_error(err: sqlx::Error) -> EngineError {
31 if let Some(db_err) = err.as_database_error()
32 && let Some(code) = db_err.code()
33 {
34 // SQLSTATE codes are 5-char strings. Match the Q11-pinned
35 // pair first; everything else falls through to Transport.
36 match code.as_ref() {
37 // unique_violation
38 "23505" => {
39 return EngineError::Conflict(ConflictKind::RotationConflict(
40 db_err.message().to_string(),
41 ));
42 }
43 // serialization_failure / deadlock_detected
44 "40001" | "40P01" => {
45 return EngineError::Contention(ContentionKind::LeaseConflict);
46 }
47 _ => {}
48 }
49 }
50 EngineError::Transport {
51 backend: "postgres",
52 source: Box::new(err),
53 }
54}
55
56impl From<sqlx::Error> for PostgresTransportError {
57 fn from(err: sqlx::Error) -> Self {
58 PostgresTransportError(err)
59 }
60}
61
62/// Thin newtype over `sqlx::Error` so a blanket
63/// `From<sqlx::Error> for EngineError` doesn't conflict with
64/// ff-core's own orphan rules when downstream crates add their own.
65/// Backend-internal call sites use [`map_sqlx_error`] directly; this
66/// wrapper exists for future cases where we want to attach a
67/// typed source to a different `EngineError` variant.
68#[derive(Debug)]
69pub struct PostgresTransportError(pub sqlx::Error);
70
71impl std::fmt::Display for PostgresTransportError {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 write!(f, "postgres transport: {}", self.0)
74 }
75}
76
77impl std::error::Error for PostgresTransportError {
78 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
79 Some(&self.0)
80 }
81}