From 145d3945711a0da628a7099bbce8cbc0e5cee422 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 28 Jul 2026 16:08:23 -0700 Subject: [PATCH] fix(cf-workers): log the request-body pipe's rejection instead of dropping it `forward` piped the inbound body into a FixedLengthStream and discarded the `pipe_to` promise, on the reasoning that a pipe failure also errors `readable` so the awaited outbound fetch would surface it. Production says otherwise. When the pipe fails the origin sees a truncated request and closes without replying; the runtime turns that into a Cloudflare-minted `error code: 520` -- a 16-byte text/plain body with no origin request id and no error document. The proxy relays that status faithfully, so the caller gets a 520 that no AWS SDK can deserialize, and the reason the pipe failed is gone. Observe the promise on a spawned task and log the rejection. The distinct causes -- inbound body short of or past the declared Content-Length (either errors the FixedLengthStream), the inbound stream erroring, the outbound fetch cancelling `readable` -- are indistinguishable downstream but named here. Diagnostic only: no behaviour change, and `forward` still returns as soon as the outbound response headers arrive. Cargo.lock is included because it was stale on main at the workspace's previous version; the pre-commit clippy hook regenerates it on every run. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 20 +++++++------- crates/cf-workers/src/backend.rs | 45 +++++++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 983e46d..f4453cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1314,7 +1314,7 @@ dependencies = [ [[package]] name = "multistore" -version = "0.7.1" +version = "0.7.2" dependencies = [ "async-trait", "base64", @@ -1341,7 +1341,7 @@ dependencies = [ [[package]] name = "multistore-cf-workers" -version = "0.7.1" +version = "0.7.2" dependencies = [ "async-trait", "bytes", @@ -1366,7 +1366,7 @@ dependencies = [ [[package]] name = "multistore-cf-workers-example" -version = "0.7.1" +version = "0.7.2" dependencies = [ "bytes", "console_error_panic_hook", @@ -1391,7 +1391,7 @@ dependencies = [ [[package]] name = "multistore-lambda" -version = "0.7.1" +version = "0.7.2" dependencies = [ "bytes", "http", @@ -1411,7 +1411,7 @@ dependencies = [ [[package]] name = "multistore-metering" -version = "0.7.1" +version = "0.7.2" dependencies = [ "bytes", "futures", @@ -1423,7 +1423,7 @@ dependencies = [ [[package]] name = "multistore-oidc-provider" -version = "0.7.1" +version = "0.7.2" dependencies = [ "base64", "chrono", @@ -1442,7 +1442,7 @@ dependencies = [ [[package]] name = "multistore-path-mapping" -version = "0.7.1" +version = "0.7.2" dependencies = [ "multistore", "percent-encoding", @@ -1451,7 +1451,7 @@ dependencies = [ [[package]] name = "multistore-server" -version = "0.7.1" +version = "0.7.2" dependencies = [ "axum", "bytes", @@ -1473,7 +1473,7 @@ dependencies = [ [[package]] name = "multistore-static-config" -version = "0.7.1" +version = "0.7.2" dependencies = [ "chrono", "multistore", @@ -1485,7 +1485,7 @@ dependencies = [ [[package]] name = "multistore-sts" -version = "0.7.1" +version = "0.7.2" dependencies = [ "aes-gcm", "base64", diff --git a/crates/cf-workers/src/backend.rs b/crates/cf-workers/src/backend.rs index e54e67a..64f41e3 100644 --- a/crates/cf-workers/src/backend.rs +++ b/crates/cf-workers/src/backend.rs @@ -60,6 +60,32 @@ fn new_fixed_length_stream( } } +/// Observe the request-body pipe so its failure reason reaches the logs instead +/// of being discarded. +/// +/// Spawns rather than awaits: `forward` must return as soon as the outbound +/// response headers arrive, and the pipe is still driven by that fetch's +/// backpressure. The spawned task only observes a promise — it performs no I/O +/// of its own and does not extend the request's lifetime. +/// +/// The rejection is the only place the real cause is visible — the inbound body +/// ending short of, or running past, the declared `Content-Length` (either errors +/// the `FixedLengthStream`), the inbound stream itself erroring, or the outbound +/// fetch cancelling `readable`. Downstream all of these look identical: a +/// truncated subrequest body and an origin that hangs up without responding. +fn log_pipe_rejection(pipe: js_sys::Promise, declared_len: u64) { + wasm_bindgen_futures::spawn_local(async move { + if let Err(err) = wasm_bindgen_futures::JsFuture::from(pipe).await { + tracing::warn!( + declared_content_length = declared_len, + error = ?err, + "request body pipe failed; the outbound body is truncated and the \ + origin will likely close without responding" + ); + } + }); +} + impl ProxyBackend for WorkerBackend { type ResponseBody = web_sys::Response; type Body = JsBody; @@ -104,13 +130,18 @@ impl ProxyBackend for WorkerBackend { let transform: &web_sys::TransformStream = fls.as_ref(); // The outbound fetch consuming `readable` pulls the body // through the transform; the pipe is driven by that - // backpressure, so it streams rather than buffers. The - // returned promise is intentionally dropped: a pipe - // failure (e.g. the client sending fewer/more bytes than - // Content-Length, which errors the FixedLengthStream) - // also errors `readable`, so the awaited outbound fetch - // fails and the error surfaces there. - let _ = stream.pipe_to(&transform.writable()); + // backpressure, so it streams rather than buffers. + // + // The pipe's rejection is logged rather than discarded. + // It used to be dropped on the reasoning that a pipe + // failure also errors `readable`, so the awaited outbound + // fetch would surface it. In practice it does not: the + // origin sees a truncated request, closes without + // replying, and the runtime turns that into a + // Cloudflare-minted `error code: 520` carrying no + // diagnostic — no origin request id, no error document. + // The reason the body pipe failed is then unrecoverable. + log_pipe_rejection(stream.pipe_to(&transform.writable()), len); init.set_body(&transform.readable()); } None => init.set_body(stream),