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}