1use std::time::Duration;
27use tokio::io::{AsyncReadExt, AsyncWriteExt};
28use tokio::net::TcpStream;
29use tokio::time::timeout;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct HttpTarget {
35 pub host: String,
36 pub port: u16,
37 pub path: String,
40}
41
42pub fn parse_http_url(url: &str) -> Result<HttpTarget, String> {
46 let url = url.trim();
47 let rest = url
48 .strip_prefix("http://")
49 .ok_or_else(|| {
50 if url.starts_with("https://") {
51 format!("`{url}`: https health-gates are not supported in v1 (R513-F3) — use an http:// URL or a `tcp` target")
52 } else {
53 format!("`{url}`: wait-for `http` target must be an http:// URL")
54 }
55 })?;
56
57 let (authority, path) = match rest.find('/') {
60 Some(i) => (&rest[..i], rest[i..].to_string()),
61 None => (rest, "/".to_string()),
62 };
63 if authority.is_empty() {
64 return Err(format!("`{url}`: wait-for `http` target has an empty host"));
65 }
66
67 let (host, port) = match authority.rsplit_once(':') {
69 Some((h, p)) => {
70 let port: u16 = p
71 .parse()
72 .map_err(|_| format!("`{url}`: invalid port `{p}`"))?;
73 (h.to_string(), port)
74 }
75 None => (authority.to_string(), 80),
76 };
77 if host.is_empty() {
78 return Err(format!("`{url}`: wait-for `http` target has an empty host"));
79 }
80
81 Ok(HttpTarget {
82 host,
83 port,
84 path: if path.is_empty() { "/".to_string() } else { path },
85 })
86}
87
88pub fn http_status_ok(status: u16, expect: Option<u16>) -> bool {
92 match expect {
93 Some(want) => status == want,
94 None => (200..400).contains(&status),
95 }
96}
97
98pub async fn probe_http_once(
102 target: &HttpTarget,
103 attempt_timeout: Duration,
104) -> Result<u16, String> {
105 let fut = async {
106 let mut stream = TcpStream::connect((target.host.as_str(), target.port))
107 .await
108 .map_err(|e| format!("connect {}:{}: {e}", target.host, target.port))?;
109 let req = format!(
110 "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nUser-Agent: qed-waitfor\r\nAccept: */*\r\n\r\n",
111 target.path, target.host,
112 );
113 stream
114 .write_all(req.as_bytes())
115 .await
116 .map_err(|e| format!("write request: {e}"))?;
117
118 let mut buf = Vec::with_capacity(256);
121 let mut chunk = [0u8; 256];
122 loop {
123 let n = stream
124 .read(&mut chunk)
125 .await
126 .map_err(|e| format!("read response: {e}"))?;
127 if n == 0 {
128 break;
129 }
130 buf.extend_from_slice(&chunk[..n]);
131 if buf.windows(2).any(|w| w == b"\r\n") || buf.len() > 8192 {
132 break;
133 }
134 }
135 parse_status_line(&buf)
136 };
137
138 timeout(attempt_timeout, fut)
139 .await
140 .map_err(|_| format!("no response within {}ms", attempt_timeout.as_millis()))?
141}
142
143fn parse_status_line(buf: &[u8]) -> Result<u16, String> {
146 let head = String::from_utf8_lossy(buf);
147 let first = head.lines().next().unwrap_or("").trim();
148 let mut parts = first.split_whitespace();
149 let version = parts.next().unwrap_or("");
150 if !version.starts_with("HTTP/") {
151 return Err(format!("not an HTTP response (got `{first}`)"));
152 }
153 let code = parts
154 .next()
155 .ok_or_else(|| format!("malformed status line `{first}`"))?;
156 code.parse::<u16>()
157 .map_err(|_| format!("malformed status code in `{first}`"))
158}
159
160pub async fn probe_tcp_once(addr: &str, attempt_timeout: Duration) -> Result<(), String> {
164 let addr = addr.trim();
165 if addr.rsplit_once(':').is_none_or(|(_, p)| p.is_empty()) {
168 return Err(format!("`{addr}`: wait-for `tcp` target must be `host:port`"));
169 }
170 let fut = TcpStream::connect(addr);
171 match timeout(attempt_timeout, fut).await {
172 Ok(Ok(_stream)) => Ok(()),
173 Ok(Err(e)) => Err(format!("connect {addr}: {e}")),
174 Err(_) => Err(format!(
175 "connect {addr}: no response within {}ms",
176 attempt_timeout.as_millis()
177 )),
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn parses_http_url_with_port_and_path() {
187 let t = parse_http_url("http://localhost:3000/health").unwrap();
188 assert_eq!(t.host, "localhost");
189 assert_eq!(t.port, 3000);
190 assert_eq!(t.path, "/health");
191 }
192
193 #[test]
194 fn parses_http_url_defaults_port_and_path() {
195 let t = parse_http_url("http://example.com").unwrap();
196 assert_eq!(t.host, "example.com");
197 assert_eq!(t.port, 80);
198 assert_eq!(t.path, "/");
199 }
200
201 #[test]
202 fn rejects_https_with_pointed_message() {
203 let err = parse_http_url("https://localhost/health").unwrap_err();
204 assert!(err.contains("https"), "got: {err}");
205 assert!(err.contains("R513-F3"), "got: {err}");
206 }
207
208 #[test]
209 fn rejects_non_http_scheme() {
210 assert!(parse_http_url("localhost:3000/health").is_err());
211 assert!(parse_http_url("http:///health").is_err());
212 }
213
214 #[test]
215 fn rejects_bad_port() {
216 assert!(parse_http_url("http://localhost:notaport/").is_err());
217 }
218
219 #[test]
220 fn status_ok_band_and_exact() {
221 assert!(http_status_ok(200, None));
222 assert!(http_status_ok(204, None));
223 assert!(http_status_ok(302, None));
224 assert!(!http_status_ok(404, None));
225 assert!(!http_status_ok(500, None));
226 assert!(http_status_ok(204, Some(204)));
228 assert!(!http_status_ok(200, Some(204)));
229 }
230
231 #[test]
232 fn parses_status_line() {
233 assert_eq!(parse_status_line(b"HTTP/1.1 200 OK\r\n").unwrap(), 200);
234 assert_eq!(parse_status_line(b"HTTP/1.0 503 Service Unavailable\r\n").unwrap(), 503);
235 assert!(parse_status_line(b"garbage\r\n").is_err());
236 assert!(parse_status_line(b"\x16\x03\x01garbage").is_err());
237 }
238
239 #[tokio::test]
240 async fn tcp_probe_succeeds_against_a_live_listener() {
241 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
242 let addr = listener.local_addr().unwrap().to_string();
243 let res = probe_tcp_once(&addr, Duration::from_secs(1)).await;
244 assert!(res.is_ok(), "got: {res:?}");
245 }
246
247 #[tokio::test]
248 async fn tcp_probe_fails_against_a_dead_port() {
249 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
251 let addr = listener.local_addr().unwrap().to_string();
252 drop(listener);
253 let res = probe_tcp_once(&addr, Duration::from_millis(200)).await;
254 assert!(res.is_err(), "expected connect failure on a dead port");
255 }
256
257 #[tokio::test]
258 async fn tcp_probe_rejects_missing_port() {
259 assert!(probe_tcp_once("localhost", Duration::from_millis(100))
260 .await
261 .is_err());
262 }
263
264 #[tokio::test]
265 async fn http_probe_reads_status_from_a_canned_server() {
266 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
267 let addr = listener.local_addr().unwrap();
268 tokio::spawn(async move {
269 if let Ok((mut sock, _)) = listener.accept().await {
270 let mut scratch = [0u8; 1024];
271 let _ = sock.read(&mut scratch).await;
272 let _ = sock
273 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
274 .await;
275 }
276 });
277 let target = HttpTarget {
278 host: addr.ip().to_string(),
279 port: addr.port(),
280 path: "/health".to_string(),
281 };
282 let status = probe_http_once(&target, Duration::from_secs(2)).await.unwrap();
283 assert_eq!(status, 200);
284 }
285
286 #[tokio::test]
287 async fn http_probe_surfaces_a_503() {
288 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
289 let addr = listener.local_addr().unwrap();
290 tokio::spawn(async move {
291 if let Ok((mut sock, _)) = listener.accept().await {
292 let mut scratch = [0u8; 1024];
293 let _ = sock.read(&mut scratch).await;
294 let _ = sock
295 .write_all(b"HTTP/1.1 503 Service Unavailable\r\n\r\n")
296 .await;
297 }
298 });
299 let target = HttpTarget {
300 host: addr.ip().to_string(),
301 port: addr.port(),
302 path: "/".to_string(),
303 };
304 let status = probe_http_once(&target, Duration::from_secs(2)).await.unwrap();
305 assert_eq!(status, 503);
306 assert!(!http_status_ok(status, None));
307 }
308}