1#[derive(Debug)]
2pub enum ExecError {
3 Cancelled,
4 Connect(Box<ConnectError>),
5 Config {
6 message: String,
7 hint: Option<String>,
8 },
9 InvalidParams(String),
10 #[allow(dead_code)]
14 ResultTooLarge {
15 row_count: usize,
16 payload_bytes: usize,
17 },
18 Sql {
19 sqlstate: String,
20 message: String,
21 detail: Option<String>,
22 hint: Option<String>,
23 position: Option<String>,
24 },
25 Internal(String),
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ConnectError {
30 pub error: String,
31 pub sqlstate: Option<String>,
32 pub message: Option<String>,
33 pub detail: Option<String>,
34 pub hint: Option<String>,
35 pub retryable: bool,
36}
37
38impl ConnectError {
39 pub fn new(error: impl Into<String>) -> Self {
40 let error = error.into();
41 Self {
42 hint: connect_hint_for_message(&error).or_else(|| Some(default_connect_hint())),
43 retryable: connect_retryable_for_message(&error),
44 error,
45 sqlstate: None,
46 message: None,
47 detail: None,
48 }
49 }
50
51 pub fn from_pg_error(prefix: &str, err: tokio_postgres::Error) -> Self {
52 if let Some(db) = err.as_db_error() {
53 let sqlstate = db.code().code().to_string();
54 let pg_hint = db.hint().map(std::string::ToString::to_string);
55 let hint = pg_hint
56 .clone()
57 .or_else(|| connect_hint_for_sqlstate(&sqlstate, db.message()));
58 return Self {
59 error: format!("{prefix}: {}", db.message()),
60 sqlstate: Some(sqlstate),
61 message: Some(db.message().to_string()),
62 detail: db.detail().map(std::string::ToString::to_string),
63 hint,
64 retryable: connect_retryable_for_sqlstate(db.code().code()),
65 };
66 }
67
68 let error = format!("{prefix}: {}", format_error_chain(&err));
69 Self {
70 hint: connect_hint_for_message(&error).or_else(|| Some(default_connect_hint())),
71 retryable: connect_retryable_for_message(&error),
72 error,
73 sqlstate: None,
74 message: None,
75 detail: None,
76 }
77 }
78}
79
80impl From<String> for ConnectError {
81 fn from(value: String) -> Self {
82 Self::new(value)
83 }
84}
85
86impl From<&str> for ConnectError {
87 fn from(value: &str) -> Self {
88 Self::new(value)
89 }
90}
91
92pub(super) fn map_pg_error(err: tokio_postgres::Error) -> ExecError {
93 if let Some(db) = err.as_db_error() {
94 return ExecError::Sql {
95 sqlstate: db.code().code().to_string(),
96 message: db.message().to_string(),
97 detail: db.detail().map(std::string::ToString::to_string),
98 hint: db.hint().map(std::string::ToString::to_string),
99 position: db.position().map(|p| match p {
100 tokio_postgres::error::ErrorPosition::Original(pos) => pos.to_string(),
101 tokio_postgres::error::ErrorPosition::Internal { position, .. } => {
102 position.to_string()
103 }
104 }),
105 };
106 }
107 ExecError::Internal(err.to_string())
108}
109
110pub(super) fn map_connect_error(err: tokio_postgres::Error) -> ExecError {
111 ExecError::Connect(Box::new(ConnectError::from_pg_error("connect failed", err)))
112}
113
114impl From<crate::conn::ConnectionConfigError> for ExecError {
115 fn from(err: crate::conn::ConnectionConfigError) -> Self {
116 ExecError::Config {
117 message: err.message().to_string(),
118 hint: err.hint().map(std::string::ToString::to_string),
119 }
120 }
121}
122
123fn format_error_chain(err: &(dyn std::error::Error + 'static)) -> String {
124 let mut out = err.to_string();
125 let mut source = err.source();
126 while let Some(err) = source {
127 let part = err.to_string();
128 if !part.is_empty() && !out.contains(&part) {
129 out.push_str(": ");
130 out.push_str(&part);
131 }
132 source = err.source();
133 }
134 out
135}
136
137fn connect_retryable_for_sqlstate(sqlstate: &str) -> bool {
138 sqlstate.starts_with("08")
139 || matches!(
140 sqlstate,
141 "57P03" | "53300" | "53400" | "58000" | "58030" )
147}
148
149fn connect_hint_for_sqlstate(sqlstate: &str, message: &str) -> Option<String> {
150 let hint = match sqlstate {
151 "28P01" => {
152 "password authentication failed; check --user and --password-secret-env PGPASSWORD, or use an authentication method accepted by pg_hba.conf"
153 }
154 "28000" => {
155 if message.contains("role") && message.contains("does not exist") {
156 "PostgreSQL rejected the role; check --user/PGUSER, create the role, or for local peer auth use a matching OS user or --ssh-sudo-user postgres with --ssh-remote-socket"
157 } else {
158 "PostgreSQL authentication or authorization failed; check pg_hba.conf, --user/PGUSER, database access, and whether peer/password auth is expected"
159 }
160 }
161 "3D000" => {
162 "database does not exist; check --dbname/PGDATABASE or connect to the postgres maintenance database to inspect available databases"
163 }
164 "57P03" => {
165 "PostgreSQL is not accepting connections yet; retry after the service finishes starting or leaves recovery/maintenance"
166 }
167 "53300" => {
168 "PostgreSQL has too many active connections; wait, terminate idle sessions, or raise max_connections/pool limits"
169 }
170 "53400" => {
171 "PostgreSQL rejected the connection because a configured limit was exceeded; inspect server logs and connection limits"
172 }
173 state if state.starts_with("08") => {
174 "connection exception from PostgreSQL; check host/port/socket path, SSH tunnel, listener status, and network reachability"
175 }
176 _ => return None,
177 };
178 Some(hint.to_string())
179}
180
181fn connect_hint_for_message(message: &str) -> Option<String> {
182 if message.contains("password missing") {
183 if message.contains("container") {
184 return Some("PostgreSQL requested password authentication but no password was provided; set --password-secret-env PGPASSWORD or --password-secret, or use peer auth over the container socket: --container-user <db-os-user> --host /var/run/postgresql (the --container-user, e.g. postgres, must match the database role)".to_string());
185 }
186 return Some("PostgreSQL requested password authentication but no password was provided; set --password-secret-env PGPASSWORD or --password-secret, or use a peer/socket authentication path".to_string());
187 }
188 if message.contains("error connecting to server") && message.contains("Operation not permitted")
189 {
190 return Some("local sandbox or OS policy blocked opening the PostgreSQL connection; in Codex request escalation, or check host/port/socket reachability outside the sandbox".to_string());
191 }
192 if message.contains("allocate ssh local port") && message.contains("Operation not permitted") {
193 return Some("local sandbox or OS policy blocked binding the SSH tunnel port; in Codex request escalation, or use SSH sudo Unix-socket bridge mode when appropriate".to_string());
194 }
195 if message.contains("SSH transport currently supports discrete connection fields") {
196 return Some("with --ssh, pass discrete connection fields such as --host/--port/--user/--dbname/--password-secret-env instead of --dsn-secret or --conninfo-secret".to_string());
197 }
198 if message.contains("container bridge") || message.contains("container transport") {
199 return Some("check the container target, runtime access, driver selection, and whether PostgreSQL is listening on the requested container-internal host/port or socket".to_string());
200 }
201 if message.contains("explicit remote PostgreSQL Unix socket") {
202 return Some("pass --ssh-remote-socket /var/run/postgresql/.s.PGSQL.5432, or set --host/PGHOST to the remote socket directory when not using sudo bridge mode".to_string());
203 }
204 if message.contains(".s.PGSQL") && message.contains("No such file or directory") {
205 return Some("the PostgreSQL Unix socket path does not exist; check the server socket directory, port-derived socket filename, and whether PostgreSQL is running".to_string());
206 }
207 if message.contains("ssh tunnel") || message.contains("start ssh") {
208 return Some("check SSH reachability/options and whether PostgreSQL is listening on the requested remote host/port or socket".to_string());
209 }
210 None
211}
212
213fn connect_retryable_for_message(message: &str) -> bool {
214 !(message.contains("password missing")
215 || message.contains("SSH transport currently supports discrete connection fields")
216 || message.contains("explicit remote PostgreSQL Unix socket"))
217}
218
219fn default_connect_hint() -> String {
220 "check --host/--port or PGHOST/PGPORT; for remote local-only PostgreSQL use --ssh user@server; for container-local PostgreSQL use --container TARGET; for containers on an SSH host combine --ssh user@server --container TARGET; for sudo-only Unix-socket access use --ssh-sudo-user with an explicit --ssh-remote-socket, or set --host/PGHOST to the remote socket directory".to_string()
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn connect_hints_classify_common_sqlstates() {
229 let auth = connect_hint_for_sqlstate("28P01", "password authentication failed");
230 assert!(
231 auth.as_deref()
232 .unwrap_or_default()
233 .contains("password authentication failed")
234 );
235 assert!(!connect_retryable_for_sqlstate("28P01"));
236
237 let missing_role = connect_hint_for_sqlstate("28000", "role \"root\" does not exist");
238 assert!(
239 missing_role
240 .as_deref()
241 .unwrap_or_default()
242 .contains("--user")
243 );
244 assert!(!connect_retryable_for_sqlstate("28000"));
245
246 let db = connect_hint_for_sqlstate("3D000", "database does not exist");
247 assert!(db.as_deref().unwrap_or_default().contains("--dbname"));
248 assert!(!connect_retryable_for_sqlstate("3D000"));
249
250 let startup = connect_hint_for_sqlstate("57P03", "cannot connect now");
251 assert!(startup.as_deref().unwrap_or_default().contains("retry"));
252 assert!(connect_retryable_for_sqlstate("57P03"));
253 }
254
255 #[test]
256 fn connect_hints_classify_transport_messages() {
257 let password = ConnectError::new("connect failed: invalid configuration: password missing");
258 assert!(
259 password
260 .hint
261 .as_deref()
262 .unwrap_or_default()
263 .contains("PGPASSWORD")
264 );
265 assert!(!password.retryable);
266
267 let container_password = connect_hint_for_message(
268 "connect through container bridge failed: invalid configuration: password missing",
269 );
270 let container_password = container_password.as_deref().unwrap_or_default();
271 assert!(container_password.contains("--container-user"));
272 assert!(container_password.contains("/var/run/postgresql"));
273
274 let sandbox = connect_hint_for_message(
275 "allocate ssh local port on 127.0.0.1 failed: Operation not permitted",
276 );
277 assert!(sandbox.as_deref().unwrap_or_default().contains("sandbox"));
278
279 let tcp_sandbox = connect_hint_for_message(
280 "connect failed: error connecting to server: Operation not permitted (os error 1)",
281 );
282 assert!(
283 tcp_sandbox
284 .as_deref()
285 .unwrap_or_default()
286 .contains("sandbox")
287 );
288
289 let socket = connect_hint_for_message(
290 "--ssh-sudo-user requires an explicit remote PostgreSQL Unix socket",
291 );
292 assert!(
293 socket
294 .as_deref()
295 .unwrap_or_default()
296 .contains("--ssh-remote-socket")
297 );
298 }
299}