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