From d3f409077249bd5703742f0cd86fc86b93d936e1 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 8 Oct 2026 15:02:57 +0200 Subject: [PATCH] feat(http): log the raw response code of failed API requests An envelope code that ResponseCode does not know deserializes to Unknown, so the error a caller sees has lost the number Proton sent. Log path, HTTP status and the raw code at debug level before mapping it. --- crates/proton-sdk/src/http.rs | 38 ++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/crates/proton-sdk/src/http.rs b/crates/proton-sdk/src/http.rs index 26fede0..4766143 100644 --- a/crates/proton-sdk/src/http.rs +++ b/crates/proton-sdk/src/http.rs @@ -708,23 +708,44 @@ pub async fn post_unauthenticated_verified( /// the typed success payload. async fn parse_response(response: reqwest::Response) -> Result { let status = response.status(); + let path = response.url().path().to_owned(); let bytes = response.bytes().await?; // Every Proton response embeds the envelope; a missing/non-success code or a // non-2xx HTTP status is an API error. `MultipleResponses` (1001) is a batch // multi-status, not a failure: the real per-item codes live in the body, so // the caller (e.g. trash/restore/delete) inspects them itself. - if let Ok(envelope) = serde_json::from_slice::(&bytes) { - if !envelope.is_success() && envelope.code != ResponseCode::MultipleResponses { - return Err(api_error(status, &bytes)); - } - } else if !status.is_success() { + let failed = match serde_json::from_slice::(&bytes) { + Ok(envelope) => !envelope.is_success() && envelope.code != ResponseCode::MultipleResponses, + Err(_) => !status.is_success(), + }; + if failed { + // A code missing from `ResponseCode` deserializes to `Unknown`, so this + // line is the only place its number survives. + tracing::debug!( + path, + http_status = status.as_u16(), + code = raw_code(&bytes), + "proton api request failed" + ); return Err(api_error(status, &bytes)); } Ok(serde_json::from_slice::(&bytes)?) } +/// The envelope's `Code` exactly as sent, before [`ResponseCode`] maps it. +fn raw_code(bytes: &[u8]) -> Option { + #[derive(serde::Deserialize)] + struct RawCode { + #[serde(rename = "Code")] + code: i64, + } + serde_json::from_slice::(bytes) + .ok() + .map(|raw| raw.code) +} + fn api_error(status: StatusCode, bytes: &[u8]) -> ProtonError { let envelope = serde_json::from_slice::(bytes).ok(); let code = envelope @@ -845,6 +866,13 @@ fn ensure_trailing_slash(url: &str) -> String { mod tests { use super::*; + #[test] + fn raw_code_keeps_codes_response_code_does_not_know() { + let body = br#"{"Code":5099,"Error":"This version of the app is no longer supported"}"#; + assert_eq!(raw_code(body), Some(5099)); + assert_eq!(raw_code(b"bad gateway"), None); + } + #[test] fn retryable_statuses() { for code in [408u16, 429, 502, 503, 504] {