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}