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}