Skip to main content

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}