reliar_store_postgres/outbox/error.rs
1//! Hand-rolled error enums for the outbox side of 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
12use crate::error::{classify_sqlstate, is_undefined_table};
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///
18/// [`Classify`] tells a dispatcher whether a failed call is worth retrying. The bare
19/// `PostgresOutboxStore` below leans on its default type parameter, gated on the default
20/// `json` feature; without it this block still shows the shape but is not compiled.
21#[cfg_attr(not(feature = "json"), doc = "```ignore")]
22#[cfg_attr(feature = "json", doc = "```no_run")]
23/// # async fn run(store: reliar_store_postgres::PostgresOutboxStore) -> Result<(), Box<dyn std::error::Error>> {
24/// use reliar_core::Classify;
25/// use reliar_outbox::{AcquireRequest, OutboxStore, WorkerId};
26///
27/// let request = AcquireRequest::new(WorkerId::generate());
28/// if let Err(err) = store.acquire(request).await {
29/// eprintln!("acquire failed ({:?}): {err}", err.kind());
30/// }
31/// # Ok(())
32/// # }
33/// ```
34#[derive(Debug)]
35#[non_exhaustive]
36pub enum PostgresOutboxError {
37 /// The relation does not resolve on this connection's `search_path` (SQLSTATE `42P01`).
38 /// Either `migrate()` has not run, or the connection's `search_path` does not resolve the
39 /// unqualified name `outbox` to the migrated schema. **Permanent** — the table does not
40 /// appear on its own. Reliar does not check this at construction (ADR 0047); this is the
41 /// first statement reporting it, with PostgreSQL's own message attached as the `source`.
42 /// Mapped from SQLSTATE `42P01` on **every** call.
43 NotMigrated {
44 /// The underlying `42P01` error, returned from [`std::error::Error::source`].
45 source: sqlx::Error,
46 },
47
48 /// Connection lost, statement timeout, pool exhausted, deadlock, or any other `sqlx`
49 /// failure not mapped to a more specific variant above. Classified by the wrapped
50 /// SQLSTATE's **class** (never blanket-transient — see the `Classify` impl below).
51 Database {
52 /// The underlying `sqlx` error.
53 source: sqlx::Error,
54 },
55}
56
57impl fmt::Display for PostgresOutboxError {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 match self {
60 Self::NotMigrated { source } => write!(
61 f,
62 "the outbox table does not resolve on this connection's search_path: {source}; \
63 run reliar_store_postgres::migrate(&pool, ..) and put the migrated schema first \
64 on search_path — in the connection URL \
65 (options=-c search_path=reliar,public) or with ALTER ROLE <role> SET \
66 search_path = reliar, public"
67 ),
68 Self::Database { source } => write!(f, "database error: {source}"),
69 }
70 }
71}
72
73impl std::error::Error for PostgresOutboxError {
74 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
75 match self {
76 Self::NotMigrated { source } | Self::Database { source } => Some(source),
77 }
78 }
79}
80
81/// Per-variant classification table — **no blanket "everything else is
82/// transient"**. A wrong verdict is not cosmetic: `Transient` burns the dispatcher's retry
83/// budget on a failure that can never succeed; `Permanent` kills a message that would have gone
84/// through on the next attempt.
85impl Classify for PostgresOutboxError {
86 fn kind(&self) -> FailureKind {
87 match self {
88 Self::NotMigrated { .. } => FailureKind::Permanent,
89 Self::Database { source } => classify_sqlstate(source),
90 }
91 }
92}
93
94impl From<sqlx::Error> for PostgresOutboxError {
95 fn from(source: sqlx::Error) -> Self {
96 map_operational_error(source)
97 }
98}
99
100/// Maps a `sqlx::Error` to a typed error, keying on SQLSTATE alone — never on message text or a
101/// constraint name — so `42P01` maps to `NotMigrated` **on every path**, and everything else falls
102/// through to `Database` for [`classify_sqlstate`] to classify.
103pub(crate) fn map_operational_error(err: sqlx::Error) -> PostgresOutboxError {
104 if is_undefined_table(&err) {
105 return PostgresOutboxError::NotMigrated { source: err };
106 }
107
108 PostgresOutboxError::Database { source: err }
109}
110
111impl crate::error::FromDatabaseError for PostgresOutboxError {
112 fn from_database_error(err: sqlx::Error) -> Self {
113 map_operational_error(err)
114 }
115}
116
117/// [`crate::PostgresOutboxStore`]'s [`reliar_outbox::OutboxEnqueue::enqueue_envelope`] failures. Enqueuing
118/// runs on the **host's** write path, where the host decides whether to retry its own
119/// transaction, so this implements [`Classify`] on the same rules as [`PostgresOutboxError`]
120/// rather than making the host re-derive which SQLSTATEs are worth retrying.
121///
122/// A duplicate [`reliar_core::MessageId`] aborts the caller's transaction rather than silently
123/// losing the message. The bare `PostgresOutboxStore` below leans on its default type
124/// parameter, gated on the default `json` feature; without it this block still shows the shape
125/// but is not compiled.
126#[cfg_attr(not(feature = "json"), doc = "```ignore")]
127#[cfg_attr(feature = "json", doc = "```no_run")]
128/// # async fn run(
129/// # store: reliar_store_postgres::PostgresOutboxStore,
130/// # pool: sqlx::PgPool,
131/// # ) -> Result<(), Box<dyn std::error::Error>> {
132/// use reliar_core::{Classify, Message};
133/// use reliar_outbox::OutboxEnqueue;
134///
135/// #[derive(serde::Serialize, serde::Deserialize)]
136/// struct OrderPlaced;
137/// impl Message for OrderPlaced {
138/// const TYPE: &'static str = "orders.placed";
139/// const VERSION: u16 = 1;
140/// }
141///
142/// let mut tx = pool.begin().await?;
143/// if let Err(err) = store.enqueue(&mut tx, OrderPlaced).await {
144/// eprintln!("enqueue failed ({:?}): {err}", err.kind());
145/// tx.rollback().await?;
146/// }
147/// # Ok(())
148/// # }
149/// ```
150#[derive(Debug)]
151#[non_exhaustive]
152pub enum EnqueueError<E> {
153 /// The configured [`reliar_core::Serializer`] rejected the body. **Permanent** — the same
154 /// body serializes the same way every time.
155 Serialize {
156 /// The serializer's own error.
157 source: E,
158 },
159
160 /// The envelope's `MessageId` already exists (`ix_outbox_message_id` violation, ADR 0044
161 /// §1) — `enqueue` uses a plain `INSERT` with no `ON CONFLICT`, so a reused id aborts the
162 /// caller's transaction rather than silently losing a message. **Permanent** — the id is
163 /// already taken.
164 Duplicate {
165 /// The id the caller tried to reuse.
166 id: MessageId,
167 },
168
169 /// Any other `sqlx` failure, classified by SQLSTATE exactly as
170 /// [`PostgresOutboxError::Database`] (including `42P01`, which classifies permanent under
171 /// the `42*` rule).
172 Database {
173 /// The underlying `sqlx` error.
174 source: sqlx::Error,
175 },
176}
177
178impl<E: fmt::Display> fmt::Display for EnqueueError<E> {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 match self {
181 Self::Serialize { source } => {
182 write!(f, "failed to serialize the envelope body: {source}")
183 }
184 Self::Duplicate { id } => write!(f, "message id {id} already exists in the outbox"),
185 Self::Database { source } => write!(f, "database error: {source}"),
186 }
187 }
188}
189
190impl<E: std::error::Error + 'static> std::error::Error for EnqueueError<E> {
191 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
192 match self {
193 Self::Serialize { source } => Some(source),
194 Self::Database { source } => Some(source),
195 Self::Duplicate { .. } => None,
196 }
197 }
198}
199
200impl<E: std::error::Error + Send + Sync + 'static> Classify for EnqueueError<E> {
201 fn kind(&self) -> FailureKind {
202 match self {
203 Self::Serialize { .. } | Self::Duplicate { .. } => FailureKind::Permanent,
204 Self::Database { source } => classify_sqlstate(source),
205 }
206 }
207}
208
209/// Maps a `sqlx::Error` from an `enqueue` `INSERT` to a typed error, keying on the constraint
210/// **name** — never on message text — so `ix_outbox_message_id` maps to `Duplicate` (ADR 0044
211/// §1: `enqueue` never binds `id`, so the only conflict an `INSERT` can hit is a reused
212/// `message_id`) and every other failure (including `42P01`) stays `Database`, for
213/// [`classify_sqlstate`] to classify. A `23505` on `pk_outbox` — which no public path can produce,
214/// since `enqueue` never binds `id` and the column defaults to `uuidv7()` — falls through to
215/// `Database` in this same `if`, never `Duplicate` (ADR 0049 §2).
216pub(crate) fn map_enqueue_error<E>(id: MessageId, err: sqlx::Error) -> EnqueueError<E> {
217 if is_constraint_violation(&err, "ix_outbox_message_id") {
218 return EnqueueError::Duplicate { id };
219 }
220
221 EnqueueError::Database { source: err }
222}
223
224/// `true` when `err` is a unique/check-constraint violation on `constraint`. Keys on the
225/// **name**, never on message text — that naming discipline is what keeps this map stable
226/// across PostgreSQL versions.
227pub(crate) fn is_constraint_violation(err: &sqlx::Error, constraint: &str) -> bool {
228 match err {
229 sqlx::Error::Database(db) => db.constraint() == Some(constraint),
230 _ => false,
231 }
232}