Skip to main content

reliar_store_postgres/
error.rs

1//! Hand-rolled error enums for the PostgreSQL provider (SRS §23, ADR 0008, contract §4, §7
2//! J1–J4).
3//!
4//! No `thiserror`, no `anyhow`. Every `Display` is payload/credential-free: a decode failure
5//! names the message id and a truncated detail, never the offending bytes. Classification is a
6//! **per-variant table, never a blanket rule** — a `Database` failure is classified by the
7//! wrapped SQLSTATE's class, not assumed transient.
8
9use core::fmt;
10
11use reliar_core::{Classify, FailureKind, MessageId};
12
13/// A failure of a [`crate::PostgresOutboxStore`] `OutboxStore`/`OutboxDeadLetters` *call* —
14/// never a property of one row's content. Row-content problems surface as
15/// [`reliar_outbox::PoisonedRow`]s instead (ADR 0008).
16#[derive(Debug)]
17#[non_exhaustive]
18pub enum PostgresStoreError {
19    /// The unqualified name `outbox` does not resolve, or resolves to a different schema than
20    /// configured. Carries the configured schema and the observed `search_path`; the `ALTER
21    /// ROLE` remedy is in the `Display` text. **Permanent.**
22    SchemaResolution {
23        /// The schema `PostgresOutboxSettings::schema` named.
24        configured: String,
25        /// The `search_path` Postgres reported at construction.
26        observed: String,
27    },
28    /// `outbox` resolved to the configured schema, but the relation itself is missing —
29    /// `migrate()` has not been run. **Permanent.** Mapped from SQLSTATE `42P01` on **every**
30    /// path, not just startup verification (contract §7 J2).
31    NotMigrated {
32        /// The configured schema.
33        schema: String,
34    },
35    /// Connection lost, statement timeout, pool exhausted, deadlock, or any other `sqlx`
36    /// failure not mapped to a more specific variant above. Classified by the wrapped
37    /// SQLSTATE's **class** (never blanket-transient — see the `Classify` impl below).
38    Database {
39        /// The underlying `sqlx` error.
40        source: sqlx::Error,
41    },
42    /// A claimed or listed row could not be turned into an `OutboxRecord` (a corrupt JSONB
43    /// remainder, an unparseable promoted column). Surfaces as a poisoned row, never as an
44    /// `acquire`/`list_dead` failure. **Permanent** — the bytes on disk do not change between
45    /// attempts.
46    Decode {
47        /// The row's message id.
48        id: MessageId,
49        /// A short, payload-free description of what failed to decode.
50        detail: String,
51    },
52    /// The row's `metadata_version` is not one this build knows how to read. **Permanent** —
53    /// it needs a newer reader, not another try.
54    UnknownMetadataVersion {
55        /// The row's message id.
56        id: MessageId,
57        /// The unrecognised version.
58        version: i32,
59    },
60    /// `enqueue` inserted a `MessageId` that already exists (`pk_outbox` violation).
61    /// **Permanent** — a reused id never succeeds on retry; the row is already there
62    /// (contract §7 J1).
63    DuplicateMessage {
64        /// The id the caller tried to reuse.
65        id: MessageId,
66    },
67    /// `PostgresOutboxSettings::schema` or `MigrateOptions::schema` is not a valid PostgreSQL
68    /// identifier (`[A-Za-z_][A-Za-z0-9_$]*`, at most 63 bytes) — checked once, before it is
69    /// ever interpolated into `SET search_path`/`dangerous_set_table_name` (contract §7 J4).
70    /// **Permanent** — configuration, not weather.
71    InvalidSchema {
72        /// The rejected schema name.
73        schema: String,
74    },
75}
76
77impl fmt::Display for PostgresStoreError {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        match self {
80            Self::SchemaResolution {
81                configured,
82                observed,
83            } => write!(
84                f,
85                "outbox did not resolve to schema \"{configured}\" (observed search_path: \
86                 \"{observed}\"); set search_path so \"{configured}\" comes first, e.g. \
87                 ALTER ROLE <role> SET search_path = {configured}, public"
88            ),
89            Self::NotMigrated { schema } => write!(
90                f,
91                "relation \"{schema}.outbox\" does not exist; call \
92                 reliar_store_postgres::migrate(&pool, ..) before constructing the store"
93            ),
94            Self::Database { source } => write!(f, "database error: {source}"),
95            Self::Decode { id, detail } => write!(f, "row {id} could not be decoded: {detail}"),
96            Self::UnknownMetadataVersion { id, version } => {
97                write!(f, "row {id} carries unknown metadata_version {version}")
98            }
99            Self::DuplicateMessage { id } => {
100                write!(f, "message id {id} already exists in the outbox")
101            }
102            Self::InvalidSchema { schema } => write!(
103                f,
104                "{schema:?} is not a valid PostgreSQL identifier (expected \
105                 [A-Za-z_][A-Za-z0-9_$]*, at most 63 bytes)"
106            ),
107        }
108    }
109}
110
111impl std::error::Error for PostgresStoreError {
112    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
113        match self {
114            Self::Database { source } => Some(source),
115            _ => None,
116        }
117    }
118}
119
120/// Per-variant classification table (contract §7 J1) — **no blanket "everything else is
121/// transient"**. A wrong verdict is not cosmetic: `Transient` burns the dispatcher's retry
122/// budget on a failure that can never succeed; `Permanent` kills a message that would have gone
123/// through on the next attempt.
124impl Classify for PostgresStoreError {
125    fn kind(&self) -> FailureKind {
126        match self {
127            Self::SchemaResolution { .. }
128            | Self::NotMigrated { .. }
129            | Self::InvalidSchema { .. }
130            | Self::DuplicateMessage { .. }
131            | Self::Decode { .. }
132            | Self::UnknownMetadataVersion { .. } => FailureKind::Permanent,
133            Self::Database { source } => classify_sqlstate(source),
134        }
135    }
136}
137
138/// Classifies a `sqlx::Error` by its wrapped SQLSTATE **class**, never by message text
139/// (contract §7 J1):
140///
141/// - **Transient** — `08*` (connection exception), `40*` (transaction rollback: deadlock,
142///   serialization failure), `53*` (insufficient resources), `55*` (object in use), `57014`
143///   (`query_canceled`, i.e. a `statement_timeout`), and any pool/IO error with no SQLSTATE at all.
144/// - **Permanent** — `22*` (data exception), `23*` (integrity constraint violation), `42*`
145///   (syntax error or access rule violation — includes `42P01`, mapped to `NotMigrated` before
146///   this function ever sees it).
147/// - Anything unrecognised classifies **Transient** — an unknown fault is more often weather
148///   than logic — but is logged at `warn` with its SQLSTATE so this table can be extended.
149pub(crate) fn classify_sqlstate(err: &sqlx::Error) -> FailureKind {
150    let sqlx::Error::Database(db) = err else {
151        // No SQLSTATE at all: a connection/IO/pool-exhaustion failure, not a data problem.
152        return FailureKind::Transient;
153    };
154    let Some(code) = db.code() else {
155        return FailureKind::Transient;
156    };
157    match code.as_ref().get(..2) {
158        Some("08" | "40" | "53" | "55") => FailureKind::Transient,
159        Some("57") if code.as_ref() == "57014" => FailureKind::Transient,
160        Some("22" | "23" | "42") => FailureKind::Permanent,
161        _ => {
162            tracing::warn!(sqlstate = %code, "unrecognised SQLSTATE; classifying transient");
163            FailureKind::Transient
164        }
165    }
166}
167
168impl From<sqlx::Error> for PostgresStoreError {
169    fn from(source: sqlx::Error) -> Self {
170        Self::Database { source }
171    }
172}
173
174/// Maps a `sqlx::Error` to a typed error, keying on SQLSTATE and constraint **name** — never on
175/// message text (§24.1) — so `42P01` maps to `NotMigrated` **on every path**, not just startup
176/// verification (contract §7 J2), and everything else falls through to `Database` for
177/// [`classify_sqlstate`] to classify.
178pub(crate) fn map_operational_error(schema: &str, err: sqlx::Error) -> PostgresStoreError {
179    if is_undefined_table(&err) {
180        return PostgresStoreError::NotMigrated {
181            schema: schema.to_owned(),
182        };
183    }
184    PostgresStoreError::Database { source: err }
185}
186
187/// [`crate::PostgresOutboxStore::enqueue`]/`enqueue_with` failures. `enqueue` runs on the
188/// **host's** write path, where the host decides whether to retry its own transaction, so this
189/// implements [`Classify`] on the same rules as [`PostgresStoreError`] rather than making the
190/// host re-derive which SQLSTATEs are worth retrying (contract §4).
191#[derive(Debug)]
192#[non_exhaustive]
193pub enum EnqueueError<E> {
194    /// The configured [`reliar_core::Serializer`] rejected the body. **Permanent** — the same
195    /// body serializes the same way every time.
196    Serialize {
197        /// The serializer's own error.
198        source: E,
199    },
200    /// The envelope's `MessageId` already exists (`pk_outbox` violation) — `enqueue` uses a
201    /// plain `INSERT` with no `ON CONFLICT`, so a reused id aborts the caller's transaction
202    /// rather than silently losing a message. **Permanent** — the id is already taken.
203    Duplicate {
204        /// The id the caller tried to reuse.
205        id: MessageId,
206    },
207    /// Any other `sqlx` failure, classified by SQLSTATE exactly as
208    /// [`PostgresStoreError::Database`] (including `42P01`, which classifies permanent under
209    /// the `42*` rule).
210    Database {
211        /// The underlying `sqlx` error.
212        source: sqlx::Error,
213    },
214}
215
216impl<E: fmt::Display> fmt::Display for EnqueueError<E> {
217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218        match self {
219            Self::Serialize { source } => {
220                write!(f, "failed to serialize the envelope body: {source}")
221            }
222            Self::Duplicate { id } => write!(f, "message id {id} already exists in the outbox"),
223            Self::Database { source } => write!(f, "database error: {source}"),
224        }
225    }
226}
227
228impl<E: std::error::Error + 'static> std::error::Error for EnqueueError<E> {
229    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
230        match self {
231            Self::Serialize { source } => Some(source),
232            Self::Database { source } => Some(source),
233            Self::Duplicate { .. } => None,
234        }
235    }
236}
237
238impl<E: std::error::Error + Send + Sync + 'static> Classify for EnqueueError<E> {
239    fn kind(&self) -> FailureKind {
240        match self {
241            Self::Serialize { .. } | Self::Duplicate { .. } => FailureKind::Permanent,
242            Self::Database { source } => classify_sqlstate(source),
243        }
244    }
245}
246
247/// Maps a `sqlx::Error` from an `enqueue` `INSERT` to a typed error, keying on the constraint
248/// **name** — never on message text (§24.1) — so `pk_outbox` maps to `Duplicate` and every
249/// other failure (including `42P01`) stays `Database`, for [`classify_sqlstate`] to classify.
250pub(crate) fn map_enqueue_error<E>(id: MessageId, err: sqlx::Error) -> EnqueueError<E> {
251    if is_constraint_violation(&err, "pk_outbox") {
252        return EnqueueError::Duplicate { id };
253    }
254    EnqueueError::Database { source: err }
255}
256
257/// `true` when `err` is a unique/check-constraint violation on `constraint`. Keys on the
258/// **name**, never on message text (§24.1's naming rule exists precisely so this map is stable
259/// across PostgreSQL versions).
260pub(crate) fn is_constraint_violation(err: &sqlx::Error, constraint: &str) -> bool {
261    match err {
262        sqlx::Error::Database(db) => db.constraint() == Some(constraint),
263        _ => false,
264    }
265}
266
267/// `true` for SQLSTATE `42P01` (`undefined_table`) — the relation is missing, i.e. `migrate()`
268/// has not run.
269pub(crate) fn is_undefined_table(err: &sqlx::Error) -> bool {
270    match err {
271        sqlx::Error::Database(db) => db.code().as_deref() == Some("42P01"),
272        _ => false,
273    }
274}
275
276/// Validates a schema name against PostgreSQL's unquoted-identifier grammar
277/// (`[A-Za-z_][A-Za-z0-9_$]*`, at most 63 bytes — Postgres's own `NAMEDATALEN` limit) **before**
278/// it is ever interpolated into `SET search_path`/`dangerous_set_table_name`, both of which
279/// build SQL text from this value rather than binding it as data (contract §7 J4). Used by both
280/// `PostgresOutboxSettings::schema` (at `connect`) and `MigrateOptions::schema` (at `migrate`),
281/// so the two validate identically.
282pub(crate) fn is_valid_schema_name(schema: &str) -> bool {
283    let mut chars = schema.chars();
284    let Some(first) = chars.next() else {
285        return false;
286    };
287    if !(first.is_ascii_alphabetic() || first == '_') {
288        return false;
289    }
290    schema.len() <= 63 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
291}