From 58e6b20c42f40a7e7f27566150e03c6a95f5a368 Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Tue, 25 Aug 2026 12:09:15 -0500 Subject: [PATCH 1/3] feat(errors): surface rate-limit headers on HTTP 429 failures Typed SDK commands discard response headers, leaving automation blind to which Datadog rate-limit rule triggered a 429. Capture X-RateLimit-* via SDK middleware and raw HTTP helpers, print readable rule/limit/remaining details on stderr, and exit with code 429. Closes #747 Co-authored-by: Cursor --- src/client.rs | 22 ++- src/commands/api.rs | 1 + src/commands/skills_remote.rs | 20 +-- src/main.rs | 17 ++- src/rate_limit.rs | 252 ++++++++++++++++++++++++++++++++++ src/raw_client.rs | 108 ++++++++++----- 6 files changed, 364 insertions(+), 56 deletions(-) create mode 100644 src/rate_limit.rs diff --git a/src/client.rs b/src/client.rs index dd52198c..def1b033 100644 --- a/src/client.rs +++ b/src/client.rs @@ -31,6 +31,24 @@ impl Middleware for BearerAuthMiddleware { } } +#[cfg(not(target_arch = "wasm32"))] +struct RateLimitCaptureMiddleware; + +#[cfg(not(target_arch = "wasm32"))] +#[async_trait] +impl Middleware for RateLimitCaptureMiddleware { + async fn handle( + &self, + req: reqwest_middleware::reqwest::Request, + extensions: &mut Extensions, + next: Next<'_>, + ) -> reqwest_middleware::Result { + let resp = next.run(req, extensions).await?; + crate::rate_limit::store_last(crate::rate_limit::extract_from_headers(resp.headers())); + Ok(resp) + } +} + // The `datadog-api-client` SDK's `Configuration.user_agent` is `pub(crate)` // with no setter, so the only way to override it from outside the crate is // via middleware that mutates the header after the SDK builds the request. @@ -130,7 +148,9 @@ pub fn make_dd_client(cfg: &Config, send_bearer: bool) -> Option Resul let resp = send_get(cfg, &url, query, "text/markdown").await?; if !resp.status().is_success() { let status = resp.status(); + let headers = resp.headers().clone(); let url = resp.url().to_string(); let body = resp.text().await.unwrap_or_default(); - return Err(HttpError { - status: status.as_u16(), - method: "GET".to_string(), - url, - body, - } - .into()); + return Err(raw_client::http_error(status.as_u16(), "GET", url, body, &headers).into()); } Ok(resp.text().await?) } @@ -213,15 +208,10 @@ async fn post(cfg: &Config, path: &str, body: serde_json::Value) -> Result Result { if !resp.status().is_success() { let status = resp.status(); + let headers = resp.headers().clone(); let url = resp.url().to_string(); let body = resp.text().await.unwrap_or_default(); - return Err(HttpError { - status: status.as_u16(), - method: method.to_string(), - url, - body, - } - .into()); + return Err(raw_client::http_error(status.as_u16(), method, url, body, &headers).into()); } Ok(resp.json().await?) } diff --git a/src/main.rs b/src/main.rs index 7ac8e9f6..6b9fc96a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod extensions; mod filter; mod formatter; mod generated; +mod rate_limit; mod raw_client; #[cfg(not(target_arch = "wasm32"))] mod runbooks; @@ -12056,15 +12057,23 @@ mod reset_sigpipe_tests { #[cfg(not(target_arch = "wasm32"))] #[tokio::main] -async fn main() -> anyhow::Result<()> { +async fn main() { reset_sigpipe(); - main_inner().await + if let Err(err) = main_inner().await { + let (msg, code) = rate_limit::cli_error(&err); + eprintln!("Error: {msg}"); + std::process::exit(code); + } } #[cfg(target_arch = "wasm32")] #[tokio::main(flavor = "current_thread")] -async fn main() -> anyhow::Result<()> { - main_inner().await +async fn main() { + if let Err(err) = main_inner().await { + let (msg, code) = rate_limit::cli_error(&err); + eprintln!("Error: {msg}"); + std::process::exit(code); + } } pub(crate) fn get_leaf_subcommand_name(matches: &clap::ArgMatches) -> Option { diff --git a/src/rate_limit.rs b/src/rate_limit.rs new file mode 100644 index 00000000..48c8d514 --- /dev/null +++ b/src/rate_limit.rs @@ -0,0 +1,252 @@ +//! Datadog `X-RateLimit-*` response header capture and readable 429 reporting. +//! +//! Typed SDK commands discard response headers; middleware in `client.rs` captures +//! rate-limit metadata so 429 errors can name the throttling rule. + +use std::sync::Mutex; + +use reqwest::header::HeaderMap; + +/// Process exit code for HTTP 429 rate-limit failures. +/// +/// On Unix the value is truncated to eight bits (`429 % 256 == 173`); check stderr +/// for the HTTP status and rate-limit rule when scripting. +pub const EXIT_RATE_LIMITED: i32 = 429; + +static LAST_CAPTURED: Mutex> = Mutex::new(None); + +/// Parsed Datadog rate-limit response headers. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RateLimitInfo { + pub name: Option, + pub limit: Option, + pub remaining: Option, + pub reset: Option, + pub period: Option, +} + +impl RateLimitInfo { + pub fn is_empty(&self) -> bool { + self.name.is_none() + && self.limit.is_none() + && self.remaining.is_none() + && self.reset.is_none() + && self.period.is_none() + } + + /// Human-readable, indented detail lines for stderr. + pub fn format_lines(&self) -> String { + let mut lines = vec!["Rate limit details:".to_string()]; + if let Some(name) = &self.name { + lines.push(format!(" rule: {name}")); + } + if let Some(limit) = &self.limit { + lines.push(format!(" limit: {limit}")); + } + if let Some(remaining) = &self.remaining { + lines.push(format!(" remaining: {remaining}")); + } + if let Some(period) = &self.period { + lines.push(format!(" period: {period}")); + } + if let Some(reset) = &self.reset { + lines.push(format!(" reset: {reset}")); + } + lines.join("\n") + } +} + +/// Extract Datadog `X-RateLimit-*` headers from an HTTP response. +pub fn extract_from_headers(headers: &HeaderMap) -> Option { + let mut info = RateLimitInfo::default(); + for (name, value) in headers { + let name_lc = name.as_str().to_ascii_lowercase(); + if !name_lc.starts_with("x-ratelimit-") { + continue; + } + let Ok(v) = value.to_str() else { + continue; + }; + match name_lc.as_str() { + "x-ratelimit-name" => info.name = Some(v.to_string()), + "x-ratelimit-limit" => info.limit = Some(v.to_string()), + "x-ratelimit-remaining" => info.remaining = Some(v.to_string()), + "x-ratelimit-reset" => info.reset = Some(v.to_string()), + "x-ratelimit-period" => info.period = Some(v.to_string()), + _ => {} + } + } + if info.is_empty() { + None + } else { + Some(info) + } +} + +/// Remember rate-limit headers from the most recent SDK HTTP response. +pub fn store_last(info: Option) { + if let Ok(mut guard) = LAST_CAPTURED.lock() { + *guard = info; + } +} + +/// Take rate-limit headers captured by SDK middleware (clears the store). +pub fn take_last_captured() -> Option { + LAST_CAPTURED.lock().ok().and_then(|mut guard| guard.take()) +} + +/// Returns true when `err` represents an HTTP 429 rate-limit failure. +pub fn is_rate_limited(err: &anyhow::Error) -> bool { + if let Some(http_err) = err.downcast_ref::() { + return http_err.status == 429; + } + + let msg = err.to_string().to_ascii_lowercase(); + msg.contains("429") + && (msg.contains("too many requests") + || msg.contains("rate limit") + || msg.contains("status code 429")) +} + +/// Format a CLI error and choose an exit code (429 for rate limits, 1 otherwise). +pub fn cli_error(err: &anyhow::Error) -> (String, i32) { + let mut msg = format!("{err:#}"); + + if let Some(http_err) = err.downcast_ref::() { + if http_err.status == 429 { + if let Some(ref info) = http_err.rate_limit { + if !info.is_empty() { + msg.push('\n'); + msg.push_str(&info.format_lines()); + } + } + msg.push_str("\nHint: rate limited — wait and retry"); + return (msg, EXIT_RATE_LIMITED); + } + } + + if is_rate_limited(err) { + if let Some(info) = take_last_captured() { + if !info.is_empty() { + msg.push('\n'); + msg.push_str(&info.format_lines()); + } + } + msg.push_str("\nHint: rate limited — wait and retry"); + return (msg, EXIT_RATE_LIMITED); + } + + (msg, 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn header_map(pairs: &[(&str, &str)]) -> HeaderMap { + let mut map = HeaderMap::new(); + for (k, v) in pairs { + map.insert( + reqwest::header::HeaderName::from_bytes(k.as_bytes()).unwrap(), + reqwest::header::HeaderValue::from_str(v).unwrap(), + ); + } + map + } + + #[test] + fn test_extract_from_headers_all_fields() { + let headers = header_map(&[ + ("x-ratelimit-name", "get_all_monitors"), + ("x-ratelimit-limit", "1000"), + ("x-ratelimit-remaining", "0"), + ("x-ratelimit-reset", "1700000000"), + ("x-ratelimit-period", "60"), + ]); + let info = extract_from_headers(&headers).expect("expected rate limit info"); + assert_eq!(info.name.as_deref(), Some("get_all_monitors")); + assert_eq!(info.limit.as_deref(), Some("1000")); + assert_eq!(info.remaining.as_deref(), Some("0")); + assert_eq!(info.reset.as_deref(), Some("1700000000")); + assert_eq!(info.period.as_deref(), Some("60")); + } + + #[test] + fn test_extract_from_headers_case_insensitive() { + let headers = header_map(&[ + ("X-RateLimit-Name", "logs_public_search_api"), + ("X-RateLimit-Limit", "10"), + ]); + let info = extract_from_headers(&headers).expect("expected rate limit info"); + assert_eq!(info.name.as_deref(), Some("logs_public_search_api")); + assert_eq!(info.limit.as_deref(), Some("10")); + } + + #[test] + fn test_extract_from_headers_empty_when_absent() { + assert!(extract_from_headers(&HeaderMap::new()).is_none()); + } + + #[test] + fn test_format_lines_readable() { + let info = RateLimitInfo { + name: Some("get_all_monitors".into()), + limit: Some("1000".into()), + remaining: Some("0".into()), + ..Default::default() + }; + let text = info.format_lines(); + assert!(text.contains("rule: get_all_monitors")); + assert!(text.contains("limit: 1000")); + assert!(text.contains("remaining: 0")); + } + + #[test] + fn test_is_rate_limited_sdk_error_string() { + let err = anyhow::anyhow!( + "failed to list monitors: error in response: status code 429 Too Many Requests" + ); + assert!(is_rate_limited(&err)); + } + + #[test] + fn test_is_rate_limited_http_error() { + let err = anyhow::Error::from(crate::raw_client::HttpError { + status: 429, + method: "GET".into(), + url: "https://api.datadoghq.com/api/v1/monitor".into(), + body: "Too Many Requests".into(), + rate_limit: None, + }); + assert!(is_rate_limited(&err)); + } + + #[test] + fn test_is_rate_limited_false_for_other_errors() { + let err = anyhow::anyhow!("failed to list monitors: status code 403 Forbidden"); + assert!(!is_rate_limited(&err)); + } + + #[test] + fn test_cli_error_rate_limited_with_captured_headers() { + store_last(Some(RateLimitInfo { + name: Some("slo_get_all".into()), + limit: Some("1000".into()), + remaining: Some("0".into()), + ..Default::default() + })); + let err = anyhow::anyhow!("failed to list slos: status code 429 Too Many Requests"); + let (msg, code) = cli_error(&err); + assert_eq!(code, EXIT_RATE_LIMITED); + assert!(msg.contains("rule: slo_get_all")); + assert!(msg.contains("Hint: rate limited")); + } + + #[test] + fn test_cli_error_non_rate_limit_exits_one() { + let err = anyhow::anyhow!("failed to get monitor: status code 404 Not Found"); + let (msg, code) = cli_error(&err); + assert_eq!(code, 1); + assert!(msg.contains("404")); + } +} diff --git a/src/raw_client.rs b/src/raw_client.rs index ed14c965..4a004088 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -12,6 +12,24 @@ pub struct HttpError { pub method: String, pub url: String, pub body: String, + pub rate_limit: Option, +} + +/// Build an [`HttpError`] from a non-success response, capturing rate-limit headers. +pub fn http_error( + status: u16, + method: impl Into, + url: impl Into, + body: impl Into, + headers: &reqwest::header::HeaderMap, +) -> HttpError { + HttpError { + status, + method: method.into(), + url: url.into(), + body: body.into(), + rate_limit: crate::rate_limit::extract_from_headers(headers), + } } impl std::fmt::Display for HttpError { @@ -20,7 +38,13 @@ impl std::fmt::Display for HttpError { f, "{} {} failed (HTTP {}): {}", self.method, self.url, self.status, self.body - ) + )?; + if let Some(ref info) = self.rate_limit { + if !info.is_empty() { + write!(f, "\n{}", info.format_lines())?; + } + } + Ok(()) } } @@ -380,14 +404,9 @@ pub async fn raw_request( let resp = req.send().await?; if !resp.status().is_success() { let status = resp.status(); + let headers = resp.headers().clone(); let text = resp.text().await.unwrap_or_default(); - return Err(HttpError { - status: status.as_u16(), - method: method_name, - url, - body: text, - } - .into()); + return Err(http_error(status.as_u16(), method_name, url, text, &headers).into()); } let resp_ct = resp @@ -436,14 +455,9 @@ pub async fn raw_get( .await?; if !resp.status().is_success() { let status = resp.status(); + let headers = resp.headers().clone(); let body = resp.text().await.unwrap_or_default(); - return Err(HttpError { - status: status.as_u16(), - method: "GET".into(), - url, - body, - } - .into()); + return Err(http_error(status.as_u16(), "GET", url, body, &headers).into()); } parse_response_json(resp).await } @@ -471,14 +485,9 @@ pub async fn raw_patch( .await?; if !resp.status().is_success() { let status = resp.status(); + let headers = resp.headers().clone(); let body = resp.text().await.unwrap_or_default(); - return Err(HttpError { - status: status.as_u16(), - method: "PATCH".into(), - url, - body, - } - .into()); + return Err(http_error(status.as_u16(), "PATCH", url, body, &headers).into()); } parse_response_json(resp).await } @@ -526,14 +535,9 @@ async fn raw_post_impl( .await?; if !resp.status().is_success() { let status = resp.status(); + let headers = resp.headers().clone(); let body = resp.text().await.unwrap_or_default(); - return Err(HttpError { - status: status.as_u16(), - method: "POST".into(), - url: url.to_string(), - body, - } - .into()); + return Err(http_error(status.as_u16(), "POST", url, body, &headers).into()); } parse_response_json(resp).await } @@ -696,14 +700,9 @@ pub async fn raw_delete(cfg: &Config, path: &str) -> anyhow::Result<()> { .await?; if !resp.status().is_success() { let status = resp.status(); + let headers = resp.headers().clone(); let body = resp.text().await.unwrap_or_default(); - return Err(HttpError { - status: status.as_u16(), - method: "DELETE".into(), - url, - body, - } - .into()); + return Err(http_error(status.as_u16(), "DELETE", url, body, &headers).into()); } Ok(()) } @@ -1187,4 +1186,41 @@ mod tests { assert_eq!(resp["data"]["id"], "12345"); cleanup_env(); } + + #[tokio::test] + async fn test_raw_get_rate_limit_includes_headers() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + std::env::set_var("PUP_MOCK_SERVER", server.url()); + + let cfg = test_cfg(); + server + .mock("GET", "/api/v1/monitor") + .with_status(429) + .with_header("x-ratelimit-name", "get_all_monitors") + .with_header("x-ratelimit-limit", "1000") + .with_header("x-ratelimit-remaining", "0") + .with_body(r#"{"errors":["Too Many Requests"]}"#) + .create_async() + .await; + + let err = super::raw_get(&cfg, "/api/v1/monitor", &[]) + .await + .expect_err("429 should fail"); + let http_err = err + .downcast_ref::() + .expect("expected HttpError"); + assert_eq!(http_err.status, 429); + let info = http_err + .rate_limit + .as_ref() + .expect("expected rate limit headers"); + assert_eq!(info.name.as_deref(), Some("get_all_monitors")); + assert_eq!(info.limit.as_deref(), Some("1000")); + assert_eq!(info.remaining.as_deref(), Some("0")); + let (msg, code) = crate::rate_limit::cli_error(&err); + assert_eq!(code, crate::rate_limit::EXIT_RATE_LIMITED); + assert!(msg.contains("rule: get_all_monitors")); + cleanup_env(); + } } From 7bc4881e711277c07ce73e51d45ecfde6fb2748e Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Tue, 25 Aug 2026 12:15:12 -0500 Subject: [PATCH 2/3] feat(errors): print rate-limit headers on stderr with --verbose When --verbose is set, successful typed commands emit captured X-RateLimit-* metadata to stderr using the same format as --output (json, yaml, table, csv, tsv), including the agent-mode JSON envelope. Co-authored-by: Cursor --- src/commands/api.rs | 3 +- src/formatter.rs | 199 +++++++++++++++++++++++++++----------------- src/main.rs | 7 ++ src/rate_limit.rs | 100 ++++++++++++++++++++++ 4 files changed, 233 insertions(+), 76 deletions(-) diff --git a/src/commands/api.rs b/src/commands/api.rs index 3629cf55..581599c0 100644 --- a/src/commands/api.rs +++ b/src/commands/api.rs @@ -255,8 +255,9 @@ pub async fn run( let body_bytes = resp.bytes().await?; + crate::rate_limit::store_last(crate::rate_limit::extract_from_headers(&resp_headers)); + if !status.is_success() { - crate::rate_limit::store_last(crate::rate_limit::extract_from_headers(&resp_headers)); let text = String::from_utf8_lossy(&body_bytes); bail!("HTTP {} {}: {}", status.as_u16(), url, text); } diff --git a/src/formatter.rs b/src/formatter.rs index e66febaf..3018381f 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -157,6 +157,9 @@ pub fn format_and_print( } let json = go_html_escape(&serde_json::to_string_pretty(&envelope)?); println!("{json}"); + if crate::rate_limit::verbose_enabled() { + crate::rate_limit::eprint_verbose_response(format, agent_mode)?; + } return Ok(()); } @@ -166,7 +169,13 @@ pub fn format_and_print( OutputFormat::Table => print_table(&value), OutputFormat::Csv => print_csv(&value), OutputFormat::Tsv => print_tsv(&value), + }?; + + if crate::rate_limit::verbose_enabled() { + crate::rate_limit::eprint_verbose_response(format, agent_mode)?; } + + Ok(()) } /// Convenience: format and print using config settings (respects -o flag, agent mode, and --jq). @@ -187,48 +196,50 @@ pub fn print_json(data: &serde_json::Value) -> Result<()> { Ok(()) } -fn print_yaml(data: &serde_json::Value) -> Result<()> { - let sorted_data = sort_json_value(data.clone()); - let yaml = serde_norway::to_string(&sorted_data)?; - print!("{yaml}"); - Ok(()) -} +/// Render a JSON value to a string using the selected output format. +pub fn format_value_to_string( + data: &serde_json::Value, + format: &OutputFormat, + agent_mode: bool, +) -> Result { + if agent_mode && *format == OutputFormat::Json { + let envelope = build_agent_envelope(data, None)?; + return Ok(go_html_escape(&serde_json::to_string_pretty(&envelope)?)); + } -/// Flatten up to two levels of nested objects into dot-notation keys. -/// e.g. {"id": "x", "attributes": {"host": "foo", "tags": {"env": "prod"}}} -/// → {"id": "x", "attributes.host": "foo", "attributes.tags.env": "prod"} -fn flatten_row(value: &serde_json::Value) -> serde_json::Value { - if let serde_json::Value::Object(map) = value { - let mut flat = serde_json::Map::new(); - for (k, v) in map { - if let serde_json::Value::Object(inner) = v { - for (ik, iv) in inner { - if let serde_json::Value::Object(inner2) = iv { - for (iik, iiv) in inner2 { - flat.insert(format!("{k}.{ik}.{iik}"), iiv.clone()); - } - } else { - flat.insert(format!("{k}.{ik}"), iv.clone()); - } - } - } else { - flat.insert(k.clone(), v.clone()); - } + match format { + OutputFormat::Json => { + let sorted_data = sort_json_value(data.clone()); + Ok(go_html_escape(&serde_json::to_string_pretty(&sorted_data)?)) } - serde_json::Value::Object(flat) - } else { - value.clone() + OutputFormat::Yaml => { + let sorted_data = sort_json_value(data.clone()); + Ok(serde_norway::to_string(&sorted_data)?) + } + OutputFormat::Table => format_table_to_string(data), + OutputFormat::Csv => format_csv_to_string(data), + OutputFormat::Tsv => format_tsv_to_string(data), } } -fn print_table(data: &serde_json::Value) -> Result<()> { +/// Format and print a JSON value to stderr (same renderers as stdout). +pub fn eprint_formatted( + data: &serde_json::Value, + format: &OutputFormat, + agent_mode: bool, +) -> Result<()> { + let rendered = format_value_to_string(data, format, agent_mode)?; + eprintln!("{rendered}"); + Ok(()) +} + +fn format_table_to_string(data: &serde_json::Value) -> Result { let raw_rows = extract_rows(data); let owned_rows: Vec = raw_rows.iter().map(|r| flatten_row(r)).collect(); let rows: Vec<&serde_json::Value> = owned_rows.iter().collect(); if rows.is_empty() { - println!("No results found"); - return Ok(()); + return Ok("No results found".to_string()); } // Collect headers from all rows @@ -295,7 +306,45 @@ fn print_table(data: &serde_json::Value) -> Result<()> { table.add_row(cells); } - println!("{table}"); + Ok(table.to_string()) +} + +fn print_yaml(data: &serde_json::Value) -> Result<()> { + let sorted_data = sort_json_value(data.clone()); + let yaml = serde_norway::to_string(&sorted_data)?; + print!("{yaml}"); + Ok(()) +} + +/// Flatten up to two levels of nested objects into dot-notation keys. +/// e.g. {"id": "x", "attributes": {"host": "foo", "tags": {"env": "prod"}}} +/// → {"id": "x", "attributes.host": "foo", "attributes.tags.env": "prod"} +fn flatten_row(value: &serde_json::Value) -> serde_json::Value { + if let serde_json::Value::Object(map) = value { + let mut flat = serde_json::Map::new(); + for (k, v) in map { + if let serde_json::Value::Object(inner) = v { + for (ik, iv) in inner { + if let serde_json::Value::Object(inner2) = iv { + for (iik, iiv) in inner2 { + flat.insert(format!("{k}.{ik}.{iik}"), iiv.clone()); + } + } else { + flat.insert(format!("{k}.{ik}"), iv.clone()); + } + } + } else { + flat.insert(k.clone(), v.clone()); + } + } + serde_json::Value::Object(flat) + } else { + value.clone() + } +} + +fn print_table(data: &serde_json::Value) -> Result<()> { + println!("{}", format_table_to_string(data)?); Ok(()) } @@ -344,14 +393,13 @@ fn csv_cell(value: Option<&serde_json::Value>) -> String { } } -fn print_csv(data: &serde_json::Value) -> Result<()> { +fn format_csv_to_string(data: &serde_json::Value) -> Result { let raw_rows = extract_rows(data); if raw_rows.is_empty() { - return Ok(()); + return Ok(String::new()); } - // Deep-flatten every row so all nested sub-fields become columns. let flat_rows: Vec> = raw_rows .iter() .map(|r| { @@ -361,7 +409,6 @@ fn print_csv(data: &serde_json::Value) -> Result<()> { }) .collect(); - // Collect all headers, preserving first-seen insertion order. let mut header_set = std::collections::HashSet::new(); let mut headers: Vec = Vec::new(); for row in &flat_rows { @@ -373,26 +420,28 @@ fn print_csv(data: &serde_json::Value) -> Result<()> { } headers.sort(); - // Print header row. - println!( - "{}", - headers - .iter() - .map(|h| csv_escape(h)) - .collect::>() - .join(",") - ); - - // Print data rows. + let mut lines = vec![headers + .iter() + .map(|h| csv_escape(h)) + .collect::>() + .join(",")]; for row in &flat_rows { - let line = headers - .iter() - .map(|h| csv_escape(&csv_cell(row.get(h.as_str())))) - .collect::>() - .join(","); - println!("{line}"); + lines.push( + headers + .iter() + .map(|h| csv_escape(&csv_cell(row.get(h.as_str())))) + .collect::>() + .join(","), + ); } + Ok(lines.join("\n")) +} +fn print_csv(data: &serde_json::Value) -> Result<()> { + let rendered = format_csv_to_string(data)?; + if !rendered.is_empty() { + println!("{rendered}"); + } Ok(()) } @@ -402,14 +451,13 @@ fn tsv_escape(s: &str) -> String { s.replace('\t', "\\t") } -fn print_tsv(data: &serde_json::Value) -> Result<()> { +fn format_tsv_to_string(data: &serde_json::Value) -> Result { let raw_rows = extract_rows(data); if raw_rows.is_empty() { - return Ok(()); + return Ok(String::new()); } - // Deep-flatten every row so all nested sub-fields become columns. let flat_rows: Vec> = raw_rows .iter() .map(|r| { @@ -419,7 +467,6 @@ fn print_tsv(data: &serde_json::Value) -> Result<()> { }) .collect(); - // Collect all headers, preserving first-seen insertion order. let mut header_set = std::collections::HashSet::new(); let mut headers: Vec = Vec::new(); for row in &flat_rows { @@ -431,26 +478,28 @@ fn print_tsv(data: &serde_json::Value) -> Result<()> { } headers.sort(); - // Print header row. - println!( - "{}", - headers - .iter() - .map(|h| tsv_escape(h)) - .collect::>() - .join("\t") - ); - - // Print data rows. + let mut lines = vec![headers + .iter() + .map(|h| tsv_escape(h)) + .collect::>() + .join("\t")]; for row in &flat_rows { - let line = headers - .iter() - .map(|h| tsv_escape(&csv_cell(row.get(h.as_str())))) - .collect::>() - .join("\t"); - println!("{line}"); + lines.push( + headers + .iter() + .map(|h| tsv_escape(&csv_cell(row.get(h.as_str())))) + .collect::>() + .join("\t"), + ); } + Ok(lines.join("\n")) +} +fn print_tsv(data: &serde_json::Value) -> Result<()> { + let rendered = format_tsv_to_string(data)?; + if !rendered.is_empty() { + println!("{rendered}"); + } Ok(()) } diff --git a/src/main.rs b/src/main.rs index 6b9fc96a..1e691efe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,6 +73,9 @@ pub(crate) struct Cli { /// trust prompt). For durable trust, use `trusted_sites` in config instead. #[arg(long, global = true)] trust_site: bool, + /// Print Datadog rate-limit response headers to stderr (formatted like --output) + #[arg(short = 'v', long, global = true)] + verbose: bool, #[command(subcommand)] command: Commands, } @@ -12716,6 +12719,7 @@ async fn main_inner() -> anyhow::Result<()> { if cli.read_only { cfg.read_only = true; } + crate::rate_limit::set_verbose(cli.verbose); // Captured before the merge: `cfg.jq` also carries an inherited `PUP_FILTER`, // which must not be mistaken for an explicit `--jq`. let jq_flag_passed = cli.jq.is_some(); @@ -16748,6 +16752,9 @@ async fn main_inner() -> anyhow::Result<()> { silent, verbose, } => { + if verbose { + crate::rate_limit::set_verbose(true); + } commands::api::run( &cfg, &endpoint, diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 48c8d514..9fc51802 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -3,10 +3,19 @@ //! Typed SDK commands discard response headers; middleware in `client.rs` captures //! rate-limit metadata so 429 errors can name the throttling rule. +use std::cell::Cell; use std::sync::Mutex; +use anyhow::Result; use reqwest::header::HeaderMap; +use crate::config::OutputFormat; +use crate::formatter; + +thread_local! { + static VERBOSE_ENABLED: Cell = const { Cell::new(false) }; +} + /// Process exit code for HTTP 429 rate-limit failures. /// /// On Unix the value is truncated to eight bits (`429 % 256 == 173`); check stderr @@ -15,6 +24,16 @@ pub const EXIT_RATE_LIMITED: i32 = 429; static LAST_CAPTURED: Mutex> = Mutex::new(None); +/// Enable or disable verbose rate-limit reporting for the current thread. +pub fn set_verbose(enabled: bool) { + VERBOSE_ENABLED.with(|v| v.set(enabled)); +} + +/// Returns true when `--verbose` was passed for this invocation. +pub fn verbose_enabled() -> bool { + VERBOSE_ENABLED.with(|v| v.get()) +} + /// Parsed Datadog rate-limit response headers. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct RateLimitInfo { @@ -54,6 +73,26 @@ impl RateLimitInfo { } lines.join("\n") } + + pub fn to_json_value(&self) -> serde_json::Value { + let mut map = serde_json::Map::new(); + if let Some(name) = &self.name { + map.insert("name".into(), name.clone().into()); + } + if let Some(limit) = &self.limit { + map.insert("limit".into(), limit.clone().into()); + } + if let Some(remaining) = &self.remaining { + map.insert("remaining".into(), remaining.clone().into()); + } + if let Some(period) = &self.period { + map.insert("period".into(), period.clone().into()); + } + if let Some(reset) = &self.reset { + map.insert("reset".into(), reset.clone().into()); + } + serde_json::Value::Object(map) + } } /// Extract Datadog `X-RateLimit-*` headers from an HTTP response. @@ -95,6 +134,23 @@ pub fn take_last_captured() -> Option { LAST_CAPTURED.lock().ok().and_then(|mut guard| guard.take()) } +/// Peek at captured rate-limit headers without clearing the store. +pub fn peek_last_captured() -> Option { + LAST_CAPTURED.lock().ok().and_then(|guard| guard.clone()) +} + +/// When `--verbose` is set, print captured rate-limit headers to stderr using +/// the same output format as the command payload. +pub fn eprint_verbose_response(format: &OutputFormat, agent_mode: bool) -> Result<()> { + let Some(info) = peek_last_captured() else { + return Ok(()); + }; + if info.is_empty() { + return Ok(()); + } + formatter::eprint_formatted(&info.to_json_value(), format, agent_mode) +} + /// Returns true when `err` represents an HTTP 429 rate-limit failure. pub fn is_rate_limited(err: &anyhow::Error) -> bool { if let Some(http_err) = err.downcast_ref::() { @@ -249,4 +305,48 @@ mod tests { assert_eq!(code, 1); assert!(msg.contains("404")); } + + #[test] + fn test_eprint_verbose_response_json() { + set_verbose(true); + store_last(Some(RateLimitInfo { + name: Some("get_all_monitors".into()), + limit: Some("1000".into()), + remaining: Some("999".into()), + ..Default::default() + })); + let rendered = formatter::format_value_to_string( + &RateLimitInfo { + name: Some("get_all_monitors".into()), + limit: Some("1000".into()), + remaining: Some("999".into()), + ..Default::default() + } + .to_json_value(), + &OutputFormat::Json, + false, + ) + .expect("json format"); + assert!(rendered.contains("\"limit\": \"1000\"")); + assert!(rendered.contains("\"name\": \"get_all_monitors\"")); + set_verbose(false); + } + + #[test] + fn test_eprint_verbose_response_table() { + let rendered = formatter::format_value_to_string( + &RateLimitInfo { + name: Some("logs_public_search_api".into()), + limit: Some("10".into()), + remaining: Some("7".into()), + ..Default::default() + } + .to_json_value(), + &OutputFormat::Table, + false, + ) + .expect("table format"); + assert!(rendered.contains("logs_public_search_api")); + assert!(rendered.contains("10")); + } } From ffd237a0de859df4abc1ea187ddad2132e605148 Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Tue, 25 Aug 2026 12:34:30 -0500 Subject: [PATCH 3/3] fix(wasm): gate verbose rate-limit stderr behind native build Browser WASM compiles formatter via lib.rs, which does not include the rate_limit module. Skip verbose rate-limit printing in browser builds. Co-authored-by: Cursor --- src/formatter.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/formatter.rs b/src/formatter.rs index 3018381f..fb18846f 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -157,6 +157,7 @@ pub fn format_and_print( } let json = go_html_escape(&serde_json::to_string_pretty(&envelope)?); println!("{json}"); + #[cfg(not(feature = "browser"))] if crate::rate_limit::verbose_enabled() { crate::rate_limit::eprint_verbose_response(format, agent_mode)?; } @@ -171,6 +172,7 @@ pub fn format_and_print( OutputFormat::Tsv => print_tsv(&value), }?; + #[cfg(not(feature = "browser"))] if crate::rate_limit::verbose_enabled() { crate::rate_limit::eprint_verbose_response(format, agent_mode)?; }