1use std::fmt::Write as _;
2
3use thiserror::Error;
4
5#[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 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 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}