Skip to content

fix(proxy): invoke response_body_filter for bodyless upstream responses#853

Open
jsulmont wants to merge 2 commits intocloudflare:mainfrom
jsulmont:fix/body-filter-bodyless-responses
Open

fix(proxy): invoke response_body_filter for bodyless upstream responses#853
jsulmont wants to merge 2 commits intocloudflare:mainfrom
jsulmont:fix/body-filter-bodyless-responses

Conversation

@jsulmont
Copy link
Copy Markdown

@jsulmont jsulmont commented Apr 9, 2026

Summary

Closes #852. Invoke response_body_filter once with body=None, end_of_stream=true when the upstream response has no body (204/304), so users can inject a synthesized body in the canonical
filter pattern.

Bug

When the upstream returns 204 or 304, the H1 client emits a single HttpTask::Header(end_of_stream=true) and never produces a HttpTask::Body; the H2 path similarly emits a header frame with eos=true and no DATA. The existing call to response_body_filter in h{1,2}_response_filter lives in the HttpTask::Body arm, so the filter is unreachable for these responses.

This makes the documented pattern impossible — a filter that mutates the status from 204 to 200 in response_filter has no way to inject a body in response_body_filter, because the body filter is never called.

Fix

After the upstream task processing loop in proxy_h1.rs and proxy_h2.rs, detect the bodyless case (batch contains Header(end_of_stream=true) with no Body task) and invoke response_body_filter once with body=None, end_of_stream=true via a new maybe_synthesize_body_filter_call helper on HttpProxy.

If the user's filter returns non-empty bytes, the in-flight header task is updated: end_of_stream cleared, Content-Length rewritten to the injected length, Transfer-Encoding removed. A HttpTask::Body(Some(bytes), true) is appended so the downstream writer ships the synthesized body.

The helper handles two task-batch shapes: [Header(end=true)] and [Header(end=true), Done]. Any batch that contains a Body task (regardless of end flag) is still handled by the existing HttpTask::Body arm.

Non-regression

  • No change to the trait signature of response_body_filter.
  • No change to the body flow for responses that have an upstream body — those still go through the existing HttpTask::Body arm.
  • For responses where the user's filter does not inject bytes (the common case), response_body_filter is now called once at end_of_stream=true with body=None. This aligns with the documented contract ("called once at end of stream") which was previously only honored for responses with bodies. Existing filters that check if let Some(b) = body are unaffected.

Verification

A regression test is included in this PR at pingora-proxy/tests/test_body_filter_bodyless.rs. It's a self-contained test binary (no openresty dependency) that spawns:

  • A pure-Rust HTTP/1 mock upstream returning 204 No Content on /no-body and 200 + "Hello World!\n" on everything else
  • A pingora proxy with the canonical pattern (mutate 204 -> 200 in response_filter, inject "<synthesized/>" in response_body_filter)

Two #[tokio::test]s:

  • test_body_filter_reaches_204_upstream — asserts the downstream client receives the injected body
  • test_body_filter_passthrough_on_200_upstream — asserts the 200 path is byte-for-byte forwarded (non-regression)
cargo test -p pingora-proxy --test test_body_filter_bodyless

On this branch (with the fix), both tests pass.

On stock main (without the fix), the 204 test fails with an empty body:

---- test_body_filter_reaches_204_upstream stdout ----
assertion `left == right` failed: expected synthesized body, got ""
   left: []
  right: [60, 115, 121, 110, 116, 104, 101, 115, 105, 122, 101, 100, 47, 62]

Diff

~77 lines of fix + ~200 lines of regression test:

  • pingora-proxy/src/proxy_h1.rs — new helper + call site
  • pingora-proxy/src/proxy_h2.rs — call site (helper is shared via HttpProxy impl)
  • pingora-proxy/tests/test_body_filter_bodyless.rs — new self-contained regression test binary

jsulmont added 2 commits April 9, 2026 14:14
 When the upstream returns 204/304, the H1 client emits a single
 HttpTask::Header(end=true) and no HttpTask::Body; the H2 path produces
 a header frame with eos=true and no DATA. The existing call to
 response_body_filter lives in the HttpTask::Body arm, so the filter is
 unreachable — making the canonical pattern (mutate status to 200 in
 response_filter, inject body in response_body_filter) impossible.

 After the upstream task loop in proxy_h1/h2.rs, detect the bodyless
 case and invoke response_body_filter once with body=None,
 end_of_stream=true. If the filter returns non-empty bytes, clear the
 header's end flag, rewrite Content-Length, and append a
 HttpTask::Body. Behavior is unchanged for responses with a body.
@Erio-Harrison
Copy link
Copy Markdown

Erio-Harrison commented Apr 9, 2026

Love this way haha. It’s much cleaner than my original approach. Previously, I was thinking of putting the injection logic inside h1_response_filter. That would require splitting a Header(end=true) into two tasks: Header(end=false) and Body(data, true), and also changing the return type.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

response_body_filter not called after overriding 204/304 status in response_filter

2 participants