Skip to main content

reliar_store_postgres/
error.rs

1//! Hand-rolled error enums for the PostgreSQL provider (ADR 0008).
2//!
3//! No `thiserror`, no `anyhow`. Every `Display` is payload/credential-free: a decode failure
4//! names the message id and a truncated detail, never the offending bytes. Classification is a
5//! **per-variant table, never a blanket rule** — a `Database` failure is classified by the
6//! wrapped SQLSTATE's class, not assumed transient.
7
8use core::fmt;
9
10use reliar_core::{Classify, FailureKind, MessageId};
11
12/// A failure of a [`crate::PostgresOutboxStore`] `OutboxStore`/`OutboxDeadLetters` *call* —
13/// never a property of one row's content. Row-content problems surface as
14/// [`reliar_outbox::PoisonedRow`]s instead (ADR 0008).
15///
16/// [`Classify`] tells a dispatcher whether a failed call is worth retrying:
17///
18/// ```no_run
19/// # async fn run(store: reliar_store_postgres::PostgresOutboxStore) -> Result<(), Box<dyn std::error::Error>> {
20/// use reliar_core::Classify;
21/// use reliar_outbox::{AcquireRequest, OutboxStore, WorkerId};
22///
23/// let request = AcquireRequest::new(WorkerId::generate());
24/// if let Err(err) = store.acquire(request).await {
25///     eprintln!("acquire failed ({:?}): {err}", err.kind());
26/// }
27/// # Ok(())
28/// # }
29/// ```
30#[derive(Debug)]
31#[non_exhaustive]
32pub enum PostgresStoreError {
33    /// The unqualified name `outbox` does not resolve, or resolves to a different schema than
34    /// configured. Carries the configured schema and the observed `search_path`; the `ALTER
35    /// ROLE` remedy is in the `Display` text. **Permanent.**
36    SchemaResolution {
37        /// The schema `PostgresOutboxSettings::schema` named.
38        configured: String,
39        /// The `search_path` Postgres reported at construction.
40        observed: String,
41    },
42
43    /// `outbox` resolved to the configured schema, but the relation itself is missing —
44    /// `migrate()` has not been run. **Permanent.** Mapped from SQLSTATE `42P01` on **every**
45    /// path, not just startup verification.
46    NotMigrated {
47        /// The configured schema.
48        schema: String,
49    },
50
51    /// Connection lost, statement timeout, pool exhausted, deadlock, or any other `sqlx`
52    /// failure not mapped to a more specific variant above. Classified by the wrapped
53    /// SQLSTATE's **class** (never blanket-transient — see the `Classify` impl below).
54    Database {
55        /// The underlying `sqlx` error.
56        source: sqlx::Error,
57    },
58
59    /// A claimed or listed row could not be turned into an `OutboxRecord` (a corrupt JSONB
60    /// remainder, an unparseable promoted column). Surfaces as a poisoned row, never as an
61    /// `acquire`/`list_dead` failure. **Permanent** — the bytes on disk do not change between
62    /// attempts.
63    Decode {
64        /// The row's message id.
65        id: MessageId,
66        /// A short, payload-free description of what failed to decode.
67        detail: String,
68    },
69
70    /// The row's `metadata_version` is not one this build knows how to read. **Permanent** —
71    /// it needs a newer reader, not another try.
72    UnknownMetadataVersion {
73        /// The row's message id.
74        id: MessageId,
75        /// The unrecognised version.
76        version: i32,
77    },
78
79    /// `enqueue` inserted a `MessageId` that already exists (`pk_outbox` violation).
80    /// **Permanent** — a reused id never succeeds on retry; the row is already there.
81    DuplicateMessage {
82        /// The id the caller tried to reuse.
83        id: MessageId,
84    },
85
86    /// `PostgresOutboxSettings::schema` or `MigrateOptions::schema` is not a valid PostgreSQL
87    /// identifier (`[a-z_][a-z0-9_$]*`, at most 63 bytes, **lowercase only**) — checked once,
88    /// before it is ever interpolated into `SET search_path`/`dangerous_set_table_name`.
89    /// Lowercase-only rather than merely case-insensitive: PostgreSQL folds an
90    /// *unquoted* identifier to lowercase, so an uppercase configured name and the schema it
91    /// actually resolves to would silently disagree unless every one of `migrate()`'s,
92    /// `verify_schema`'s and the host's own `search_path` configuration happened to quote it the
93    /// same way everywhere — rejecting it up front removes the whole class of mismatch.
94    /// **Permanent** — configuration, not weather.
95    InvalidSchema {
96        /// The rejected schema name.
97        schema: String,
98    },
99
100    /// The connected server's `server_version_num` is below [`crate::MIN_SERVER_VERSION_NUM`]
101    /// (PostgreSQL 18, ADR 0041 / human decision #47) — **no older-version fallback**. Checked
102    /// at [`crate::PostgresOutboxStore::connect`], **before** the `search_path` verification
103    /// above: a wrong server version explains a missing relation, and the reverse is never
104    /// true. Carries no connection string, host, or credentials. **Permanent.**
105    UnsupportedServerVersion {
106        /// [`crate::MIN_SERVER_VERSION_NUM`], restated on the value so this variant is
107        /// self-describing without a second lookup.
108        required: u32,
109        /// The `server_version_num` this connection reported.
110        detected: u32,
111    },
112}
113
114impl fmt::Display for PostgresStoreError {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        match self {
117            Self::SchemaResolution {
118                configured,
119                observed,
120            } => write!(
121                f,
122                "outbox did not resolve to schema \"{configured}\" (observed search_path: \
123                 \"{observed}\"); set search_path so \"{configured}\" comes first, e.g. \
124                 ALTER ROLE <role> SET search_path = {configured}, public"
125            ),
126            Self::NotMigrated { schema } => write!(
127                f,
128                "relation \"{schema}.outbox\" does not exist; call \
129                 reliar_store_postgres::migrate(&pool, ..) before constructing the store"
130            ),
131            Self::Database { source } => write!(f, "database error: {source}"),
132            Self::Decode { id, detail } => write!(f, "row {id} could not be decoded: {detail}"),
133            Self::UnknownMetadataVersion { id, version } => {
134                write!(f, "row {id} carries unknown metadata_version {version}")
135            }
136            Self::DuplicateMessage { id } => {
137                write!(f, "message id {id} already exists in the outbox")
138            }
139            Self::InvalidSchema { schema } => write!(
140                f,
141                "{schema:?} is not a valid PostgreSQL identifier (expected \
142                 [a-z_][a-z0-9_$]*, at most 63 bytes, lowercase only — PostgreSQL folds an \
143                 unquoted identifier to lowercase, so an uppercase name would resolve \
144                 inconsistently)"
145            ),
146            Self::UnsupportedServerVersion { required, detected } => write!(
147                f,
148                "PostgreSQL 18 or newer is required (server_version_num >= {required}); \
149                 detected {detected} — there is no supported way to run Reliar below the floor"
150            ),
151        }
152    }
153}
154
155impl std::error::Error for PostgresStoreError {
156    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
157        match self {
158            Self::Database { source } => Some(source),
159            _ => None,
160        }
161    }
162}
163
164/// Per-variant classification table — **no blanket "everything else is
165/// transient"**. A wrong verdict is not cosmetic: `Transient` burns the dispatcher's retry
166/// budget on a failure that can never succeed; `Permanent` kills a message that would have gone
167/// through on the next attempt.
168impl Classify for PostgresStoreError {
169    fn kind(&self) -> FailureKind {
170        match self {
171            Self::SchemaResolution { .. }
172            | Self::NotMigrated { .. }
173            | Self::InvalidSchema { .. }
174            | Self::DuplicateMessage { .. }
175            | Self::Decode { .. }
176            | Self::UnknownMetadataVersion { .. }
177            | Self::UnsupportedServerVersion { .. } => FailureKind::Permanent,
178            Self::Database { source } => classify_sqlstate(source),
179        }
180    }
181}
182
183/// Classifies a `sqlx::Error` by its wrapped SQLSTATE **class**, never by message text:
184///
185/// - **Transient** — `08*` (connection exception), `40*` (transaction rollback: deadlock,
186///   serialization failure), `53*` (insufficient resources), `55*` (object in use), `57014`
187///   (`query_canceled`, i.e. a `statement_timeout`), and any pool/IO error with no SQLSTATE at all.
188/// - **Permanent** — `22*` (data exception), `23*` (integrity constraint violation), `42*`
189///   (syntax error or access rule violation — includes `42P01`, mapped to `NotMigrated` before
190///   this function ever sees it).
191/// - Anything unrecognised classifies **Transient** — an unknown fault is more often weather
192///   than logic — but is logged at `warn` with its SQLSTATE so this table can be extended.
193pub(crate) fn classify_sqlstate(err: &sqlx::Error) -> FailureKind {
194    let sqlx::Error::Database(db) = err else {
195        // No SQLSTATE at all: a connection/IO/pool-exhaustion failure, not a data problem.
196        return FailureKind::Transient;
197    };
198    let Some(code) = db.code() else {
199        return FailureKind::Transient;
200    };
201
202    match code.as_ref().get(..2) {
203        Some("08" | "40" | "53" | "55") => FailureKind::Transient,
204        Some("57") if code.as_ref() == "57014" => FailureKind::Transient,
205        Some("22" | "23" | "42") => FailureKind::Permanent,
206        _ => {
207            tracing::warn!(sqlstate = %code, "unrecognised SQLSTATE; classifying transient");
208
209            FailureKind::Transient
210        }
211    }
212}
213
214impl From<sqlx::Error> for PostgresStoreError {
215    fn from(source: sqlx::Error) -> Self {
216        Self::Database { source }
217    }
218}
219
220/// Maps a `sqlx::Error` to a typed error, keying on SQLSTATE and constraint **name** — never on
221/// message text — so `42P01` maps to `NotMigrated` **on every path**, not just startup
222/// verification, and everything else falls through to `Database` for
223/// [`classify_sqlstate`] to classify.
224pub(crate) fn map_operational_error(schema: &str, err: sqlx::Error) -> PostgresStoreError {
225    if is_undefined_table(&err) {
226        return PostgresStoreError::NotMigrated {
227            schema: schema.to_owned(),
228        };
229    }
230
231    PostgresStoreError::Database { source: err }
232}
233
234/// [`crate::PostgresOutboxStore`]'s [`reliar_outbox::OutboxEnqueue::enqueue_envelope`] failures. Enqueuing
235/// runs on the **host's** write path, where the host decides whether to retry its own
236/// transaction, so this implements [`Classify`] on the same rules as [`PostgresStoreError`]
237/// rather than making the host re-derive which SQLSTATEs are worth retrying.
238///
239/// A duplicate [`reliar_core::MessageId`] aborts the caller's transaction rather than silently
240/// losing the message:
241///
242/// ```no_run
243/// # async fn run(
244/// #     store: reliar_store_postgres::PostgresOutboxStore,
245/// #     pool: sqlx::PgPool,
246/// # ) -> Result<(), Box<dyn std::error::Error>> {
247/// use reliar_core::{Classify, Message};
248/// use reliar_outbox::OutboxEnqueue;
249///
250/// #[derive(serde::Serialize, serde::Deserialize)]
251/// struct OrderPlaced;
252/// impl Message for OrderPlaced {
253///     const TYPE: &'static str = "orders.placed";
254///     const VERSION: u16 = 1;
255/// }
256///
257/// let mut tx = pool.begin().await?;
258/// if let Err(err) = store.enqueue(&mut tx, OrderPlaced).await {
259///     eprintln!("enqueue failed ({:?}): {err}", err.kind());
260///     tx.rollback().await?;
261/// }
262/// # Ok(())
263/// # }
264/// ```
265#[derive(Debug)]
266#[non_exhaustive]
267pub enum EnqueueError<E> {
268    /// The configured [`reliar_core::Serializer`] rejected the body. **Permanent** — the same
269    /// body serializes the same way every time.
270    Serialize {
271        /// The serializer's own error.
272        source: E,
273    },
274
275    /// The envelope's `MessageId` already exists (`pk_outbox` violation) — `enqueue` uses a
276    /// plain `INSERT` with no `ON CONFLICT`, so a reused id aborts the caller's transaction
277    /// rather than silently losing a message. **Permanent** — the id is already taken.
278    Duplicate {
279        /// The id the caller tried to reuse.
280        id: MessageId,
281    },
282
283    /// Any other `sqlx` failure, classified by SQLSTATE exactly as
284    /// [`PostgresStoreError::Database`] (including `42P01`, which classifies permanent under
285    /// the `42*` rule).
286    Database {
287        /// The underlying `sqlx` error.
288        source: sqlx::Error,
289    },
290}
291
292impl<E: fmt::Display> fmt::Display for EnqueueError<E> {
293    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294        match self {
295            Self::Serialize { source } => {
296                write!(f, "failed to serialize the envelope body: {source}")
297            }
298            Self::Duplicate { id } => write!(f, "message id {id} already exists in the outbox"),
299            Self::Database { source } => write!(f, "database error: {source}"),
300        }
301    }
302}
303
304impl<E: std::error::Error + 'static> std::error::Error for EnqueueError<E> {
305    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
306        match self {
307            Self::Serialize { source } => Some(source),
308            Self::Database { source } => Some(source),
309            Self::Duplicate { .. } => None,
310        }
311    }
312}
313
314impl<E: std::error::Error + Send + Sync + 'static> Classify for EnqueueError<E> {
315    fn kind(&self) -> FailureKind {
316        match self {
317            Self::Serialize { .. } | Self::Duplicate { .. } => FailureKind::Permanent,
318            Self::Database { source } => classify_sqlstate(source),
319        }
320    }
321}
322
323/// Maps a `sqlx::Error` from an `enqueue` `INSERT` to a typed error, keying on the constraint
324/// **name** — never on message text — so `pk_outbox` maps to `Duplicate` and every
325/// other failure (including `42P01`) stays `Database`, for [`classify_sqlstate`] to classify.
326pub(crate) fn map_enqueue_error<E>(id: MessageId, err: sqlx::Error) -> EnqueueError<E> {
327    if is_constraint_violation(&err, "pk_outbox") {
328        return EnqueueError::Duplicate { id };
329    }
330
331    EnqueueError::Database { source: err }
332}
333
334/// `true` when `err` is a unique/check-constraint violation on `constraint`. Keys on the
335/// **name**, never on message text — that naming discipline is what keeps this map stable
336/// across PostgreSQL versions.
337pub(crate) fn is_constraint_violation(err: &sqlx::Error, constraint: &str) -> bool {
338    match err {
339        sqlx::Error::Database(db) => db.constraint() == Some(constraint),
340        _ => false,
341    }
342}
343
344/// `true` for SQLSTATE `42P01` (`undefined_table`) — the relation is missing, i.e. `migrate()`
345/// has not run.
346pub(crate) fn is_undefined_table(err: &sqlx::Error) -> bool {
347    match err {
348        sqlx::Error::Database(db) => db.code().as_deref() == Some("42P01"),
349        _ => false,
350    }
351}
352
353/// Validates a schema name against PostgreSQL's unquoted-identifier grammar, restricted to
354/// **lowercase** (`[a-z_][a-z0-9_$]*`, at most 63 bytes — Postgres's own `NAMEDATALEN` limit)
355/// **before** it is ever interpolated into `SET search_path`/`dangerous_set_table_name`, both of
356/// which build SQL text from this value rather than binding it as data. Used by
357/// both `PostgresOutboxSettings::schema` (at `connect`) and `MigrateOptions::schema` (at
358/// `migrate`), so the two validate identically.
359///
360/// **Lowercase only, not merely case-insensitive (ADR 0040 §5).** PostgreSQL folds an *unquoted*
361/// identifier to lowercase, so `schema = "Foo"` would migrate into a schema literally named
362/// `"Foo"` (quoted) while every unqualified reference — the claim, `stats()`, the host's own
363/// `search_path` — resolves the unquoted, lowercase-folded `foo` instead: a mismatch this crate
364/// cannot detect from inside a single connection's `search_path`, since the host's own connection
365/// string or `ALTER ROLE` also has to agree, and cannot be fixed here. Rejecting every uppercase
366/// character removes the class of mismatch instead of chasing it through four call sites.
367pub(crate) fn is_valid_schema_name(schema: &str) -> bool {
368    let mut chars = schema.chars();
369    let Some(first) = chars.next() else {
370        return false;
371    };
372
373    if !(first.is_ascii_lowercase() || first == '_') {
374        return false;
375    }
376
377    schema.len() <= 63
378        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '$')
379}