Skip to main content

reliar_store_postgres/inbox/
error.rs

1//! Hand-rolled error enum for the inbox side of the PostgreSQL provider (ADR 0008, inbox contract
2//! §3).
3
4use core::fmt;
5
6use reliar_core::{Classify, FailureKind, MessageId};
7
8use crate::error::{classify_sqlstate, is_undefined_table};
9
10/// A failure of a [`crate::PostgresInboxStore`] call (inbox contract §3). Deliberately smaller
11/// than [`crate::PostgresOutboxError`]: the inbox has no `enqueue`/`acquire` analog, so it needs
12/// no `Decode`/`UnknownMetadataVersion`/`DuplicateMessage` variant.
13#[derive(Debug)]
14#[non_exhaustive]
15pub enum PostgresInboxError {
16    /// The relation does not resolve on this connection's `search_path` (SQLSTATE `42P01`).
17    /// Either `migrate()` has not run, or the connection's `search_path` does not resolve the
18    /// unqualified name `inbox` to the migrated schema. **Permanent** — the table does not
19    /// appear on its own. Reliar does not check this at construction (ADR 0047); this is the
20    /// first statement reporting it, with PostgreSQL's own message attached as the `source`.
21    /// Mapped from SQLSTATE `42P01` on **every** call.
22    NotMigrated {
23        /// The underlying `42P01` error, returned from [`std::error::Error::source`].
24        source: sqlx::Error,
25    },
26
27    /// [`crate::PostgresInboxStore`]'s `InboxStore::complete` matched zero rows — reachable only
28    /// by misuse (completing without a preceding `Claimed` claim, after the claiming
29    /// transaction aborted, or completing a row that has since gone dead — `complete`'s own
30    /// guard is `completed_at IS NULL AND dead_at IS NULL`, ADR 0042 A.2.4). **Permanent** —
31    /// retrying the same call changes nothing.
32    NotClaimed {
33        /// The scope the caller completed under.
34        scope: String,
35        /// The message id the caller tried to complete.
36        message_id: MessageId,
37    },
38
39    /// `PostgresInboxSettings::validate` rejected the settings — currently only
40    /// `max_attempts == 0` (ADR 0042 A.2.4). Checked at
41    /// [`crate::PostgresInboxStore::with_settings`], before any query runs. **Permanent.**
42    InvalidSettings {
43        /// A payload-free description of what was rejected.
44        message: String,
45    },
46
47    /// Any other `sqlx` failure, classified by SQLSTATE exactly as
48    /// [`crate::PostgresOutboxError::Database`].
49    Database {
50        /// The underlying `sqlx` error.
51        source: sqlx::Error,
52    },
53}
54
55impl fmt::Display for PostgresInboxError {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        match self {
58            Self::NotMigrated { source } => write!(
59                f,
60                "the inbox table does not resolve on this connection's search_path: {source}; \
61                 run reliar_store_postgres::migrate(&pool, ..) and put the migrated schema first \
62                 on search_path — in the connection URL \
63                 (options=-c search_path=reliar,public) or with ALTER ROLE <role> SET \
64                 search_path = reliar, public"
65            ),
66            Self::NotClaimed { scope, message_id } => write!(
67                f,
68                "no claimed inbox row for scope {scope:?}, message {message_id}; complete() may \
69                 only follow a Claimed claim() in the same transaction, and never a dead one"
70            ),
71            Self::InvalidSettings { message } => {
72                write!(f, "invalid PostgresInboxSettings: {message}")
73            }
74            Self::Database { source } => write!(f, "database error: {source}"),
75        }
76    }
77}
78
79impl std::error::Error for PostgresInboxError {
80    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
81        match self {
82            Self::NotMigrated { source } | Self::Database { source } => Some(source),
83            Self::NotClaimed { .. } | Self::InvalidSettings { .. } => None,
84        }
85    }
86}
87
88/// Per-variant classification table, exactly as [`crate::PostgresOutboxError`]'s: no blanket
89/// "everything else is transient".
90impl Classify for PostgresInboxError {
91    fn kind(&self) -> FailureKind {
92        match self {
93            Self::NotMigrated { .. } | Self::NotClaimed { .. } | Self::InvalidSettings { .. } => {
94                FailureKind::Permanent
95            }
96            Self::Database { source } => classify_sqlstate(source),
97        }
98    }
99}
100
101impl From<sqlx::Error> for PostgresInboxError {
102    fn from(source: sqlx::Error) -> Self {
103        map_operational_error(source)
104    }
105}
106
107/// Maps a `sqlx::Error` to a typed error, keying on SQLSTATE — never on message text — so
108/// `42P01` maps to `NotMigrated` **on every path**, and everything else falls through to
109/// `Database` for [`classify_sqlstate`] to classify. Mirrors the outbox's own operational-error
110/// mapping (ADR 0047 §4 — the inbox gains this mapping on every path, not only at a construction
111/// check that no longer exists).
112pub(crate) fn map_operational_error(err: sqlx::Error) -> PostgresInboxError {
113    if is_undefined_table(&err) {
114        return PostgresInboxError::NotMigrated { source: err };
115    }
116
117    PostgresInboxError::Database { source: err }
118}
119
120impl crate::error::FromDatabaseError for PostgresInboxError {
121    fn from_database_error(err: sqlx::Error) -> Self {
122        map_operational_error(err)
123    }
124}