Skip to main content

agent_first_psql/db/
errors.rs

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    /// Reserved variant: the inline path now soft-truncates instead of
11    /// erroring, but this variant is preserved so future callers (e.g. a
12    /// dedicated row-limit gate) can opt back into the hard-fail shape.
13    #[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" // cannot_connect_now
142                | "53300" // too_many_connections
143                | "53400" // configuration_limit_exceeded
144                | "58000" // system_error
145                | "58030" // io_error
146        )
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}