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