From d3465c341d450de906f8fdddb4527a74c8011010 Mon Sep 17 00:00:00 2001 From: datadog-bits <263423550+datadog-bits@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:19:44 +0000 Subject: [PATCH] fix(on-call): handle empty 200 body in pages get GET /api/v2/on-call/pages/{id} can return HTTP 200 with an empty body (content-length: 0). parse_response_json fed the empty body straight to serde_json, producing "EOF while parsing value at line 1 column 0" and failing `pup on-call pages get`. Treat an empty or whitespace-only success body as JSON null in the shared parse_response_json helper, so every raw_* caller (raw_get/raw_post/etc.) degrades gracefully instead of crashing, mirroring the existing 204 No Content handling. - Guard empty/whitespace bodies in raw_client::parse_response_json - Add raw_get unit tests (empty, whitespace-only, valid JSON) - Add pages_get regression test for empty 200 body Closes #638 --- src/commands/on_call.rs | 23 +++++++++++++ src/raw_client.rs | 75 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/src/commands/on_call.rs b/src/commands/on_call.rs index e26cc05f..aef359ce 100644 --- a/src/commands/on_call.rs +++ b/src/commands/on_call.rs @@ -830,6 +830,29 @@ mod tests { cleanup_env(); } + // Regression test for #638: the on-call pages GET endpoint can return a 200 + // with an empty body (content-length: 0). pages_get must succeed instead of + // failing with "EOF while parsing value at line 1 column 0". + #[tokio::test] + async fn test_on_call_pages_get_empty_body() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("GET", "/api/v2/on-call/pages/12345") + .with_status(200) + .with_header("content-type", "application/json") + .with_body("") + .create_async() + .await; + let result = super::pages_get(&cfg, "12345").await; + assert!( + result.is_ok(), + "pages_get with empty body failed: {:?}", + result.err() + ); + cleanup_env(); + } + #[tokio::test] async fn test_on_call_pages_get_not_found() { let _lock = lock_env().await; diff --git a/src/raw_client.rs b/src/raw_client.rs index eb78158d..a645c40f 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -37,6 +37,13 @@ impl std::error::Error for HttpError {} async fn parse_response_json(resp: reqwest::Response) -> anyhow::Result { use serde::Deserialize; let bytes = resp.bytes().await?; + // Some endpoints return a success status (e.g. 200) with an empty body, such + // as GET /api/v2/on-call/pages/{id} which responds with content-length: 0. + // Treat an empty or whitespace-only body as JSON null rather than failing + // with "EOF while parsing value at line 1 column 0". + if bytes.iter().all(u8::is_ascii_whitespace) { + return Ok(serde_json::Value::Null); + } let mut de = serde_json::Deserializer::from_slice(&bytes); de.disable_recursion_limit(); let de = serde_stacker::Deserializer::new(&mut de); @@ -1106,4 +1113,72 @@ mod tests { ); cleanup_env(); } + + /// Regression test: a 200 response with an empty body must parse as JSON null + /// instead of failing with "EOF while parsing value at line 1 column 0". + #[tokio::test] + async fn test_raw_get_empty_body_returns_null() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock = server + .mock("GET", "/api/v2/on-call/pages/12345") + .with_status(200) + .with_header("content-type", "application/json") + .with_body("") + .create_async() + .await; + let resp = super::raw_get(&cfg, "/api/v2/on-call/pages/12345", &[]).await; + assert!( + resp.is_ok(), + "raw_get with empty body failed: {:?}", + resp.err() + ); + assert_eq!(resp.unwrap(), serde_json::Value::Null); + cleanup_env(); + } + + /// A whitespace-only body is also unparseable JSON and must be treated as null. + #[tokio::test] + async fn test_raw_get_whitespace_body_returns_null() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock = server + .mock("GET", "/api/v2/on-call/pages/12345") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(" \n\t ") + .create_async() + .await; + let resp = super::raw_get(&cfg, "/api/v2/on-call/pages/12345", &[]).await; + assert!( + resp.is_ok(), + "raw_get with whitespace body failed: {:?}", + resp.err() + ); + assert_eq!(resp.unwrap(), serde_json::Value::Null); + cleanup_env(); + } + + /// A non-empty JSON body must still parse normally (the empty-body guard must + /// not shadow the regular parse path). + #[tokio::test] + async fn test_raw_get_nonempty_body_parses() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock = server + .mock("GET", "/api/v2/on-call/pages/12345") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"data": {"id": "12345"}}"#) + .create_async() + .await; + let resp = super::raw_get(&cfg, "/api/v2/on-call/pages/12345", &[]) + .await + .expect("raw_get with JSON body should succeed"); + assert_eq!(resp["data"]["id"], "12345"); + cleanup_env(); + } }