1use std::time::{Duration, Instant};
8
9use sqlx::SqlStr;
10
11use crate::SqlError;
12
13pub async fn execute_with_timeout<T>(
34 timeout_secs: Option<u64>,
35 sql: SqlStr,
36 op: impl AsyncFnOnce(SqlStr) -> Result<T, sqlx::Error>,
37) -> Result<T, SqlError> {
38 let result = match timeout_secs {
39 Some(secs) if secs > 0 => {
40 let start = Instant::now();
41 let err_sql = sql.clone();
42 tokio::time::timeout(Duration::from_secs(secs), op(sql))
43 .await
44 .map_err(|_| SqlError::QueryTimeout {
45 elapsed_secs: start.elapsed().as_secs_f64(),
46 sql: err_sql.as_str().to_owned(),
47 })?
48 }
49 _ => op(sql).await,
50 };
51 result.map_err(|e| match e {
52 sqlx::Error::Io(_)
53 | sqlx::Error::Tls(_)
54 | sqlx::Error::Protocol(_)
55 | sqlx::Error::PoolTimedOut
56 | sqlx::Error::PoolClosed
57 | sqlx::Error::WorkerCrashed => SqlError::Connection(e.to_string()),
58 e => SqlError::Query(e.to_string()),
59 })
60}
61
62#[cfg(test)]
63mod tests {
64 use super::*;
65
66 #[tokio::test]
67 async fn fast_query_succeeds_with_timeout() {
68 let result = execute_with_timeout(Some(5), SqlStr::from_static("SELECT 1"), |_| async { Ok(42) }).await;
69 assert_eq!(result.expect("should succeed"), 42);
70 }
71
72 #[tokio::test]
73 async fn query_error_propagates_as_app_error() {
74 let result: Result<i32, SqlError> = execute_with_timeout(Some(5), SqlStr::from_static("BAD SQL"), |_| async {
75 Err(sqlx::Error::Configuration("syntax error".into()))
76 })
77 .await;
78 let err = result.expect_err("should fail");
79 assert!(
80 matches!(err, SqlError::Query(ref msg) if msg.contains("syntax error")),
81 "unexpected error: {err}"
82 );
83 }
84
85 #[tokio::test]
86 async fn connectivity_errors_become_connection_errors() {
87 for error in [
88 sqlx::Error::Io(std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset")),
89 sqlx::Error::Tls("bad certificate".into()),
90 sqlx::Error::Protocol("unexpected eof".into()),
91 sqlx::Error::PoolTimedOut,
92 sqlx::Error::PoolClosed,
93 sqlx::Error::WorkerCrashed,
94 ] {
95 let message = error.to_string();
96 let result: Result<i32, SqlError> =
97 execute_with_timeout(None, SqlStr::from_static("SELECT 1"), |_| async { Err(error) }).await;
98 let err = result.expect_err("should fail");
99 assert!(matches!(err, SqlError::Connection(ref m) if *m == message), "{err:?}");
100 assert_eq!(err.to_string(), format!("Database error: {message}"));
101 }
102 }
103
104 #[tokio::test]
105 async fn slow_query_times_out() {
106 let result: Result<i32, SqlError> =
107 execute_with_timeout(Some(1), SqlStr::from_static("SELECT SLEEP(60)"), |_| async {
108 tokio::time::sleep(Duration::from_mins(1)).await;
109 Ok(0)
110 })
111 .await;
112 let err = result.expect_err("should time out");
113 match err {
114 SqlError::QueryTimeout { elapsed_secs, sql } => {
115 assert!(elapsed_secs >= 0.9, "elapsed too small: {elapsed_secs}");
116 assert_eq!(sql, "SELECT SLEEP(60)");
117 }
118 other => panic!("expected QueryTimeout, got: {other}"),
119 }
120 }
121
122 #[tokio::test]
123 async fn none_timeout_runs_without_limit() {
124 let result = execute_with_timeout(None, SqlStr::from_static("SELECT 1"), |_| async { Ok(1) }).await;
125 assert_eq!(result.expect("should succeed"), 1);
126 }
127
128 #[tokio::test]
129 async fn zero_timeout_disables_limit() {
130 let result = execute_with_timeout(Some(0), SqlStr::from_static("SELECT 1"), |_| async { Ok(1) }).await;
131 assert_eq!(result.expect("should succeed"), 1);
132 }
133}