From 9b140e5a102e41cce5d3d18eecfbb35b45d7ca73 Mon Sep 17 00:00:00 2001 From: Ashwin Renjith Date: Tue, 10 Mar 2026 21:54:34 +0530 Subject: [PATCH 1/3] fix(http): unify executor request path with retry handling --- .changeset/executor-retry-consistency.md | 5 + src/client.rs | 136 +++++++++++++++++++++++ src/executor.rs | 101 ++++++++++++++++- 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 .changeset/executor-retry-consistency.md diff --git a/.changeset/executor-retry-consistency.md b/.changeset/executor-retry-consistency.md new file mode 100644 index 00000000..830dffb9 --- /dev/null +++ b/.changeset/executor-retry-consistency.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Route generic executor HTTP sends through shared retry behavior for 429 responses to match resilient helper paths and improve throttling robustness. diff --git a/src/client.rs b/src/client.rs index eb838852..e4478a5d 100644 --- a/src/client.rs +++ b/src/client.rs @@ -48,12 +48,148 @@ pub async fn send_with_retry( build_request().send().await } +/// Send an already-built request with retry on 429 when the request can be +/// safely cloned for subsequent attempts. +/// +/// If the request cannot be cloned (e.g. streaming body), this falls back to a +/// single send. +pub async fn send_builder_with_retry( + request: reqwest::RequestBuilder, +) -> Result { + let Some(template) = request.try_clone() else { + return request.send().await; + }; + + for attempt in 0..MAX_RETRIES { + // `template` came from `try_clone()`, so this should remain cloneable. + // If clone unexpectedly fails, degrade safely to a single send. + let attempt_request = match template.try_clone() { + Some(r) => r, + None => return template.send().await, + }; + + let resp = attempt_request.send().await?; + + if resp.status() != reqwest::StatusCode::TOO_MANY_REQUESTS { + return Ok(resp); + } + + let retry_after = resp + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(1 << attempt); // 1, 2, 4 seconds + + tokio::time::sleep(std::time::Duration::from_secs(retry_after)).await; + } + + match template.try_clone() { + Some(r) => r.send().await, + None => template.send().await, + } +} + #[cfg(test)] mod tests { use super::*; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + fn reason_phrase(code: u16) -> &'static str { + match code { + 200 => "OK", + 429 => "Too Many Requests", + 500 => "Internal Server Error", + _ => "Status", + } + } + + async fn spawn_response_server( + responses: Vec<(u16, Option)>, + ) -> ( + String, + Arc, + tokio::task::JoinHandle>, + ) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let hits = Arc::new(AtomicUsize::new(0)); + let hits_clone = Arc::clone(&hits); + + let handle = tokio::spawn(async move { + for (status, retry_after) in responses { + let (mut socket, _) = listener.accept().await?; + let mut buf = [0u8; 2048]; + let _ = socket.read(&mut buf).await?; + hits_clone.fetch_add(1, Ordering::SeqCst); + + let body = b"{}"; + let mut extra_headers = String::new(); + if let Some(v) = retry_after { + extra_headers.push_str(&format!("Retry-After: {v}\r\n")); + } + + let response = format!( + "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{}\r\n", + status, + reason_phrase(status), + body.len(), + extra_headers + ); + socket.write_all(response.as_bytes()).await?; + socket.write_all(body).await?; + } + Ok(()) + }); + + (format!("http://{addr}/"), hits, handle) + } #[test] fn build_client_succeeds() { assert!(build_client().is_ok()); } + + #[tokio::test] + async fn send_with_retry_retries_on_429() { + let (url, hits, handle) = spawn_response_server(vec![(429, Some(0)), (200, None)]).await; + let client = reqwest::Client::new(); + + let resp = send_with_retry(|| client.get(&url)).await.unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::OK); + assert_eq!(hits.load(Ordering::SeqCst), 2); + + handle.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn send_builder_with_retry_retries_on_429() { + let (url, hits, handle) = spawn_response_server(vec![(429, Some(0)), (200, None)]).await; + let client = reqwest::Client::new(); + let request = client.get(&url).header("x-test", "1"); + + let resp = send_builder_with_retry(request).await.unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::OK); + assert_eq!(hits.load(Ordering::SeqCst), 2); + + handle.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn send_builder_with_retry_does_not_retry_non_429() { + let (url, hits, handle) = spawn_response_server(vec![(500, None)]).await; + let client = reqwest::Client::new(); + let request = client.get(&url); + + let resp = send_builder_with_retry(request).await.unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(hits.load(Ordering::SeqCst), 1); + + handle.await.unwrap().unwrap(); + } } diff --git a/src/executor.rs b/src/executor.rs index 49101ece..84b8443a 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -413,7 +413,9 @@ pub async fn execute_method( ) .await?; - let response = request.send().await.context("HTTP request failed")?; + let response = crate::client::send_builder_with_retry(request) + .await + .context("HTTP request failed")?; let status = response.status(); let content_type = response @@ -968,6 +970,61 @@ mod tests { use super::*; use crate::discovery::{JsonSchema, JsonSchemaProperty, RestDescription, RestMethod}; use serde_json::json; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + fn reason_phrase(code: u16) -> &'static str { + match code { + 200 => "OK", + 429 => "Too Many Requests", + 500 => "Internal Server Error", + _ => "Status", + } + } + + pub(super) async fn spawn_response_server( + responses: Vec<(u16, Option, &'static str)>, + ) -> ( + String, + Arc, + tokio::task::JoinHandle>, + ) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let hits = Arc::new(AtomicUsize::new(0)); + let hits_clone = Arc::clone(&hits); + + let handle = tokio::spawn(async move { + for (status, retry_after, body) in responses { + let (mut socket, _) = listener.accept().await?; + let mut buf = [0u8; 2048]; + let _ = socket.read(&mut buf).await?; + hits_clone.fetch_add(1, Ordering::SeqCst); + + let mut extra_headers = String::new(); + if let Some(v) = retry_after { + extra_headers.push_str(&format!("Retry-After: {v}\r\n")); + } + + let response = format!( + "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{}\r\n{}", + status, + reason_phrase(status), + body.len(), + extra_headers, + body + ); + socket.write_all(response.as_bytes()).await?; + } + Ok(()) + }); + + (format!("http://{addr}/"), hits, handle) + } #[test] fn test_pagination_config_default() { @@ -1727,6 +1784,48 @@ async fn test_execute_method_missing_path_param() { .contains("Required path parameter")); } +#[tokio::test] +async fn test_execute_method_retries_on_429_in_generic_path() { + let (base, hits, handle) = + tests::spawn_response_server(vec![(429, Some(0), "{}"), (200, None, "{\"ok\":true}")]) + .await; + + let doc = RestDescription { + base_url: Some(base), + ..Default::default() + }; + let method = RestMethod { + http_method: "GET".to_string(), + path: "files".to_string(), + flat_path: Some("files".to_string()), + ..Default::default() + }; + + let result = execute_method( + &doc, + &method, + None, + None, + None, + AuthMethod::None, + None, + None, + false, + &PaginationConfig::default(), + None, + &crate::helpers::modelarmor::SanitizeMode::Warn, + &crate::formatter::OutputFormat::default(), + true, + ) + .await + .unwrap() + .unwrap(); + + assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 2); + assert_eq!(result.get("ok").and_then(|v| v.as_bool()), Some(true)); + handle.await.unwrap().unwrap(); +} + #[test] fn test_handle_error_response_non_json() { let err = handle_error_response::<()>( From b64ce742c6f75de97264d67136476a737d1e6a5a Mon Sep 17 00:00:00 2001 From: Ashwin Renjith Date: Tue, 10 Mar 2026 22:08:17 +0530 Subject: [PATCH 2/3] fix(http): harden retry policy and backoff safety --- src/client.rs | 43 ++++++++++++++++++++---------- src/executor.rs | 52 ++++++++++++++++++++++++++++++++++--- src/helpers/gmail/triage.rs | 15 ++++++----- 3 files changed, 87 insertions(+), 23 deletions(-) diff --git a/src/client.rs b/src/client.rs index e4478a5d..7ca174ce 100644 --- a/src/client.rs +++ b/src/client.rs @@ -20,6 +20,17 @@ pub fn build_client() -> Result { } const MAX_RETRIES: u32 = 3; +const MAX_RETRY_AFTER_SECS: u64 = 30; + +fn retry_delay_secs(headers: &reqwest::header::HeaderMap, attempt: u32) -> u64 { + let from_header = headers + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()); + + let backoff = 1u64 << attempt; // 1, 2, 4 seconds + from_header.unwrap_or(backoff).min(MAX_RETRY_AFTER_SECS) +} /// Send an HTTP request with automatic retry on 429 (rate limit) responses. /// Respects the `Retry-After` header; falls back to exponential backoff (1s, 2s, 4s). @@ -33,13 +44,9 @@ pub async fn send_with_retry( return Ok(resp); } - // Parse Retry-After header (seconds), fall back to exponential backoff - let retry_after = resp - .headers() - .get("retry-after") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse::().ok()) - .unwrap_or(1 << attempt); // 1, 2, 4 seconds + // Parse Retry-After (seconds), fall back to exponential backoff. + // Clamp to avoid unbounded server-controlled sleep. + let retry_after = retry_delay_secs(resp.headers(), attempt); tokio::time::sleep(std::time::Duration::from_secs(retry_after)).await; } @@ -74,12 +81,7 @@ pub async fn send_builder_with_retry( return Ok(resp); } - let retry_after = resp - .headers() - .get("retry-after") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse::().ok()) - .unwrap_or(1 << attempt); // 1, 2, 4 seconds + let retry_after = retry_delay_secs(resp.headers(), attempt); tokio::time::sleep(std::time::Duration::from_secs(retry_after)).await; } @@ -155,6 +157,21 @@ mod tests { assert!(build_client().is_ok()); } + #[test] + fn retry_delay_secs_clamps_large_retry_after() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("retry-after", HeaderValue::from_static("9999")); + assert_eq!(retry_delay_secs(&headers, 0), MAX_RETRY_AFTER_SECS); + } + + #[test] + fn retry_delay_secs_uses_exponential_fallback() { + let headers = reqwest::header::HeaderMap::new(); + assert_eq!(retry_delay_secs(&headers, 0), 1); + assert_eq!(retry_delay_secs(&headers, 1), 2); + assert_eq!(retry_delay_secs(&headers, 2), 4); + } + #[tokio::test] async fn send_with_retry_retries_on_429() { let (url, hits, handle) = spawn_response_server(vec![(429, Some(0)), (200, None)]).await; diff --git a/src/executor.rs b/src/executor.rs index 84b8443a..2ac88ca1 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -347,6 +347,10 @@ async fn handle_binary_response( Ok(None) } +fn is_retry_safe_method(http_method: &str) -> bool { + matches!(http_method, "GET" | "HEAD" | "OPTIONS" | "PUT" | "DELETE") +} + /// Executes an API method call. /// /// This is the core function of the CLI that handles: @@ -413,9 +417,13 @@ pub async fn execute_method( ) .await?; - let response = crate::client::send_builder_with_retry(request) - .await - .context("HTTP request failed")?; + let response = if is_retry_safe_method(method.http_method.as_str()) { + crate::client::send_builder_with_retry(request) + .await + .context("HTTP request failed")? + } else { + request.send().await.context("HTTP request failed")? + }; let status = response.status(); let content_type = response @@ -1826,6 +1834,44 @@ async fn test_execute_method_retries_on_429_in_generic_path() { handle.await.unwrap().unwrap(); } +#[tokio::test] +async fn test_execute_method_post_does_not_retry_on_429() { + let (base, hits, handle) = tests::spawn_response_server(vec![(429, Some(0), "{}")]).await; + + let doc = RestDescription { + base_url: Some(base), + ..Default::default() + }; + let method = RestMethod { + http_method: "POST".to_string(), + path: "files".to_string(), + flat_path: Some("files".to_string()), + ..Default::default() + }; + + let result = execute_method( + &doc, + &method, + None, + None, + None, + AuthMethod::None, + None, + None, + false, + &PaginationConfig::default(), + None, + &crate::helpers::modelarmor::SanitizeMode::Warn, + &crate::formatter::OutputFormat::default(), + true, + ) + .await; + + assert!(result.is_err()); + assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 1); + handle.await.unwrap().unwrap(); +} + #[test] fn test_handle_error_response_non_json() { let err = handle_error_response::<()>( diff --git a/src/helpers/gmail/triage.rs b/src/helpers/gmail/triage.rs index d8adffba..877ab4c2 100644 --- a/src/helpers/gmail/triage.rs +++ b/src/helpers/gmail/triage.rs @@ -46,13 +46,14 @@ pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> { // 1. List message IDs let list_url = "https://gmail.googleapis.com/gmail/v1/users/me/messages"; - let list_resp = client - .get(list_url) - .query(&[("q", query), ("maxResults", &max.to_string())]) - .bearer_auth(&token) - .send() - .await - .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to list messages: {e}")))?; + let list_resp = crate::client::send_with_retry(|| { + client + .get(list_url) + .query(&[("q", query), ("maxResults", &max.to_string())]) + .bearer_auth(&token) + }) + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to list messages: {e}")))?; if !list_resp.status().is_success() { let err = list_resp.text().await.unwrap_or_default(); From c843c76a6f1a233bf86188f9eb902a363b3fcd31 Mon Sep 17 00:00:00 2001 From: Ashwin Renjith Date: Tue, 10 Mar 2026 22:28:45 +0530 Subject: [PATCH 3/3] refactor(test): dedupe retry server helper and simplify final send --- src/client.rs | 80 ++++++++++------------------------------------- src/executor.rs | 71 +++++++---------------------------------- src/main.rs | 2 ++ src/test_utils.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 122 deletions(-) create mode 100644 src/test_utils.rs diff --git a/src/client.rs b/src/client.rs index 7ca174ce..f6a518a3 100644 --- a/src/client.rs +++ b/src/client.rs @@ -86,71 +86,13 @@ pub async fn send_builder_with_retry( tokio::time::sleep(std::time::Duration::from_secs(retry_after)).await; } - match template.try_clone() { - Some(r) => r.send().await, - None => template.send().await, - } + template.send().await } #[cfg(test)] mod tests { use super::*; - use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; - - fn reason_phrase(code: u16) -> &'static str { - match code { - 200 => "OK", - 429 => "Too Many Requests", - 500 => "Internal Server Error", - _ => "Status", - } - } - - async fn spawn_response_server( - responses: Vec<(u16, Option)>, - ) -> ( - String, - Arc, - tokio::task::JoinHandle>, - ) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let hits = Arc::new(AtomicUsize::new(0)); - let hits_clone = Arc::clone(&hits); - - let handle = tokio::spawn(async move { - for (status, retry_after) in responses { - let (mut socket, _) = listener.accept().await?; - let mut buf = [0u8; 2048]; - let _ = socket.read(&mut buf).await?; - hits_clone.fetch_add(1, Ordering::SeqCst); - - let body = b"{}"; - let mut extra_headers = String::new(); - if let Some(v) = retry_after { - extra_headers.push_str(&format!("Retry-After: {v}\r\n")); - } - - let response = format!( - "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{}\r\n", - status, - reason_phrase(status), - body.len(), - extra_headers - ); - socket.write_all(response.as_bytes()).await?; - socket.write_all(body).await?; - } - Ok(()) - }); - - (format!("http://{addr}/"), hits, handle) - } + use std::sync::atomic::Ordering; #[test] fn build_client_succeeds() { @@ -174,7 +116,11 @@ mod tests { #[tokio::test] async fn send_with_retry_retries_on_429() { - let (url, hits, handle) = spawn_response_server(vec![(429, Some(0)), (200, None)]).await; + let (url, hits, handle) = crate::test_utils::spawn_response_server(vec![ + crate::test_utils::mock_http_response(429, Some(0), "{}"), + crate::test_utils::mock_http_response(200, None, "{}"), + ]) + .await; let client = reqwest::Client::new(); let resp = send_with_retry(|| client.get(&url)).await.unwrap(); @@ -186,7 +132,11 @@ mod tests { #[tokio::test] async fn send_builder_with_retry_retries_on_429() { - let (url, hits, handle) = spawn_response_server(vec![(429, Some(0)), (200, None)]).await; + let (url, hits, handle) = crate::test_utils::spawn_response_server(vec![ + crate::test_utils::mock_http_response(429, Some(0), "{}"), + crate::test_utils::mock_http_response(200, None, "{}"), + ]) + .await; let client = reqwest::Client::new(); let request = client.get(&url).header("x-test", "1"); @@ -199,7 +149,11 @@ mod tests { #[tokio::test] async fn send_builder_with_retry_does_not_retry_non_429() { - let (url, hits, handle) = spawn_response_server(vec![(500, None)]).await; + let (url, hits, handle) = + crate::test_utils::spawn_response_server(vec![crate::test_utils::mock_http_response( + 500, None, "{}", + )]) + .await; let client = reqwest::Client::new(); let request = client.get(&url); diff --git a/src/executor.rs b/src/executor.rs index 2ac88ca1..14565502 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -978,61 +978,6 @@ mod tests { use super::*; use crate::discovery::{JsonSchema, JsonSchemaProperty, RestDescription, RestMethod}; use serde_json::json; - use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; - - fn reason_phrase(code: u16) -> &'static str { - match code { - 200 => "OK", - 429 => "Too Many Requests", - 500 => "Internal Server Error", - _ => "Status", - } - } - - pub(super) async fn spawn_response_server( - responses: Vec<(u16, Option, &'static str)>, - ) -> ( - String, - Arc, - tokio::task::JoinHandle>, - ) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let hits = Arc::new(AtomicUsize::new(0)); - let hits_clone = Arc::clone(&hits); - - let handle = tokio::spawn(async move { - for (status, retry_after, body) in responses { - let (mut socket, _) = listener.accept().await?; - let mut buf = [0u8; 2048]; - let _ = socket.read(&mut buf).await?; - hits_clone.fetch_add(1, Ordering::SeqCst); - - let mut extra_headers = String::new(); - if let Some(v) = retry_after { - extra_headers.push_str(&format!("Retry-After: {v}\r\n")); - } - - let response = format!( - "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{}\r\n{}", - status, - reason_phrase(status), - body.len(), - extra_headers, - body - ); - socket.write_all(response.as_bytes()).await?; - } - Ok(()) - }); - - (format!("http://{addr}/"), hits, handle) - } #[test] fn test_pagination_config_default() { @@ -1794,9 +1739,11 @@ async fn test_execute_method_missing_path_param() { #[tokio::test] async fn test_execute_method_retries_on_429_in_generic_path() { - let (base, hits, handle) = - tests::spawn_response_server(vec![(429, Some(0), "{}"), (200, None, "{\"ok\":true}")]) - .await; + let (base, hits, handle) = crate::test_utils::spawn_response_server(vec![ + crate::test_utils::mock_http_response(429, Some(0), "{}"), + crate::test_utils::mock_http_response(200, None, "{\"ok\":true}"), + ]) + .await; let doc = RestDescription { base_url: Some(base), @@ -1836,7 +1783,13 @@ async fn test_execute_method_retries_on_429_in_generic_path() { #[tokio::test] async fn test_execute_method_post_does_not_retry_on_429() { - let (base, hits, handle) = tests::spawn_response_server(vec![(429, Some(0), "{}")]).await; + let (base, hits, handle) = + crate::test_utils::spawn_response_server(vec![crate::test_utils::mock_http_response( + 429, + Some(0), + "{}", + )]) + .await; let doc = RestDescription { base_url: Some(base), diff --git a/src/main.rs b/src/main.rs index fb28ffcf..09147985 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,8 @@ mod schema; mod services; mod setup; mod setup_tui; +#[cfg(test)] +mod test_utils; mod text; mod token_storage; pub(crate) mod validate; diff --git a/src/test_utils.rs b/src/test_utils.rs new file mode 100644 index 00000000..e208052b --- /dev/null +++ b/src/test_utils.rs @@ -0,0 +1,79 @@ +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +pub(crate) struct MockHttpResponse { + pub status: u16, + pub retry_after_secs: Option, + pub body: &'static str, +} + +pub(crate) fn mock_http_response( + status: u16, + retry_after_secs: Option, + body: &'static str, +) -> MockHttpResponse { + MockHttpResponse { + status, + retry_after_secs, + body, + } +} + +fn reason_phrase(code: u16) -> &'static str { + match code { + 200 => "OK", + 429 => "Too Many Requests", + 500 => "Internal Server Error", + _ => "Status", + } +} + +pub(crate) async fn spawn_response_server( + responses: Vec, +) -> ( + String, + Arc, + tokio::task::JoinHandle>, +) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let hits = Arc::new(AtomicUsize::new(0)); + let hits_clone = Arc::clone(&hits); + + let handle = tokio::spawn(async move { + for MockHttpResponse { + status, + retry_after_secs, + body, + } in responses + { + let (mut socket, _) = listener.accept().await?; + let mut buf = [0u8; 2048]; + let _ = socket.read(&mut buf).await?; + hits_clone.fetch_add(1, Ordering::SeqCst); + + let mut extra_headers = String::new(); + if let Some(v) = retry_after_secs { + extra_headers.push_str(&format!("Retry-After: {v}\r\n")); + } + + let response = format!( + "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{}\r\n{}", + status, + reason_phrase(status), + body.len(), + extra_headers, + body + ); + socket.write_all(response.as_bytes()).await?; + } + Ok(()) + }); + + (format!("http://{addr}/"), hits, handle) +}