Skip to main content

ahri_tre_pgmeta/
error.rs

1use std::fmt::Write as _;
2
3use thiserror::Error;
4
5/// Errors returned by PostgreSQL metadata adapters and repositories.
6#[derive(Debug, Error)]
7pub enum PgMetaError {
8    #[error("PostgreSQL connection failed: {}", postgres_error_summary(.0))]
9    Connect(#[source] postgres::Error),
10    #[error("PostgreSQL TLS connector setup failed: {0}")]
11    Tls(#[source] native_tls::Error),
12    #[error("PostgreSQL metadata query failed: {}", postgres_error_summary(.0))]
13    Query(#[source] postgres::Error),
14    #[error(
15        "PostgreSQL metadata write failed during {operation}: {}",
16        postgres_error_summary(source)
17    )]
18    Write {
19        operation: &'static str,
20        #[source]
21        source: postgres::Error,
22    },
23    #[error("PostgreSQL metadata transaction failed: {}", postgres_error_summary(.0))]
24    Transaction(#[source] postgres::Error),
25    #[error("PostgreSQL OAuth metadata operation failed during {operation}: {source}")]
26    OAuth {
27        operation: &'static str,
28        #[source]
29        source: ahri_tre_libpq_oauth::LibpqOAuthError,
30    },
31    #[error("could not decode PostgreSQL metadata field {field}={value:?}: {message}")]
32    Decode {
33        field: &'static str,
34        value: String,
35        message: String,
36    },
37}
38
39impl PgMetaError {
40    /// Classifies adapter evidence without formatting provider text or SQL.
41    pub fn operational_category(&self) -> ahri_tre_observability::FailureCategory {
42        use ahri_tre_observability::FailureCategory;
43        if let Self::Connect(error) = self {
44            let mut source = std::error::Error::source(error);
45            while let Some(cause) = source {
46                if cause.is::<native_tls::Error>() {
47                    return FailureCategory::Tls;
48                }
49                source = cause.source();
50            }
51        }
52        let database_error = match self {
53            Self::Connect(error)
54            | Self::Query(error)
55            | Self::Transaction(error)
56            | Self::Write { source: error, .. } => error.as_db_error(),
57            _ => None,
58        };
59        if let Some(error) = database_error {
60            let code = error.code().code();
61            if code.starts_with("28") {
62                return FailureCategory::Authentication;
63            }
64            if code == "42501" {
65                return FailureCategory::Authorization;
66            }
67            if matches!(code, "42P01" | "42703" | "3F000") {
68                return FailureCategory::Compatibility;
69            }
70        }
71        match self {
72            Self::Connect(_) => FailureCategory::Connection,
73            Self::Tls(_) => FailureCategory::Tls,
74            Self::Decode { .. } => FailureCategory::Compatibility,
75            Self::Query(_) | Self::Write { .. } | Self::Transaction(_) => FailureCategory::Query,
76            Self::OAuth { source, .. } => {
77                use ahri_tre_libpq_oauth::LibpqOAuthError;
78                match source {
79                    LibpqOAuthError::AuthenticationRejected { .. }
80                    | LibpqOAuthError::InvalidToken => FailureCategory::Authentication,
81                    LibpqOAuthError::TlsRootCertificate(_) => FailureCategory::Tls,
82                    LibpqOAuthError::ConnectFailed { .. } | LibpqOAuthError::NullConnection => {
83                        FailureCategory::Connection
84                    }
85                    LibpqOAuthError::QueryFailed { .. } => FailureCategory::Query,
86                    LibpqOAuthError::UnexpectedResultShape
87                    | LibpqOAuthError::InvalidCommandRowCount { .. } => {
88                        FailureCategory::Compatibility
89                    }
90                    _ => FailureCategory::Internal,
91                }
92            }
93        }
94    }
95
96    /// Returns whether PostgreSQL identified this as an authorization failure.
97    pub fn is_authorization_failure(&self) -> bool {
98        match self {
99            Self::Connect(error)
100            | Self::Query(error)
101            | Self::Transaction(error)
102            | Self::Write { source: error, .. } => error
103                .as_db_error()
104                .is_some_and(|error| is_authorization_failure_sqlstate(Some(error.code().code()))),
105            Self::Tls(_) | Self::OAuth { .. } | Self::Decode { .. } => false,
106        }
107    }
108}
109
110pub(crate) fn is_authorization_failure_sqlstate(sqlstate: Option<&str>) -> bool {
111    matches!(sqlstate, Some("42501"))
112}
113
114fn postgres_error_summary(error: &postgres::Error) -> String {
115    match error.as_db_error() {
116        Some(db_error) => format_db_error_summary(
117            db_error.message(),
118            db_error.code().code(),
119            db_error.schema(),
120            db_error.table(),
121            db_error.column(),
122            db_error.hint(),
123        ),
124        None => error.to_string(),
125    }
126}
127
128fn format_db_error_summary(
129    message: &str,
130    sqlstate: &str,
131    schema: Option<&str>,
132    table: Option<&str>,
133    column: Option<&str>,
134    hint: Option<&str>,
135) -> String {
136    let mut summary = format!("{message} (SQLSTATE {sqlstate})");
137    if schema.is_some() || table.is_some() || column.is_some() {
138        summary.push_str("; object=");
139        match (schema, table, column) {
140            (Some(schema), Some(table), Some(column)) => {
141                let _ = write!(summary, "{schema}.{table}.{column}");
142            }
143            (Some(schema), Some(table), None) => {
144                let _ = write!(summary, "{schema}.{table}");
145            }
146            (None, Some(table), Some(column)) => {
147                let _ = write!(summary, "{table}.{column}");
148            }
149            (None, Some(table), None) => summary.push_str(table),
150            (Some(schema), None, Some(column)) => {
151                let _ = write!(summary, "{schema}.{column}");
152            }
153            (Some(schema), None, None) => summary.push_str(schema),
154            (None, None, Some(column)) => summary.push_str(column),
155            (None, None, None) => {}
156        }
157    }
158    if let Some(hint) = hint {
159        let hint = hint.trim();
160        if !hint.is_empty() {
161            let _ = write!(summary, "; hint={hint}");
162        }
163    }
164    summary
165}
166
167#[cfg(test)]
168mod tests {
169    use super::format_db_error_summary;
170
171    #[test]
172    fn db_error_summary_includes_actionable_postgres_fields() {
173        let summary = format_db_error_summary(
174            "permission denied for table domains",
175            "42501",
176            Some("public"),
177            Some("domains"),
178            None,
179            None,
180        );
181
182        assert_eq!(
183            summary,
184            "permission denied for table domains (SQLSTATE 42501); object=public.domains"
185        );
186    }
187
188    #[test]
189    fn db_error_summary_includes_hint_when_available() {
190        let summary = format_db_error_summary(
191            "relation does not exist",
192            "42P01",
193            None,
194            Some("domains"),
195            None,
196            Some("Check that the datastore schema has been created."),
197        );
198
199        assert_eq!(
200            summary,
201            "relation does not exist (SQLSTATE 42P01); object=domains; hint=Check that the datastore schema has been created."
202        );
203    }
204}