Skip to content

HTTP/2 origin: discarding 100 Continue strands the request body #13544

Description

@bryancall

Summary

When an origin sends a 100 Continue over HTTP/2 and ATS discards it below the state
machine, the request body is never forwarded. Depending on whether the origin waits for
the body, this either silently drops the body while returning 200 OK to the client, or
hangs the transaction.

This is the follow-up to the 1xx interim-response handling added in #13351. That change
correctly stops an interim response from corrupting the final response headers, but a
100 Continue is load-bearing for the request body and cannot simply be dropped.

Why the body is stranded

HttpSM deliberately withholds the request body until it observes the origin's 100:

  • proxy.config.http.send_100_continue_response defaults to 0, so
    HttpSM.cc:812 sets m_100_continue_required.
  • HttpTransact.cc:8065 only strips Expect: when m_100_continue_sent, which is false
    at that default, so Expect: 100-continue is forwarded to the origin.
  • HttpSM.cc:2226 then skips do_setup_client_request_body_tunnel() while
    m_100_continue_required is set.
  • The only path that releases the body is tunnel_handler_100_continue
    (HttpSM.cc:3136), reached via HttpTransact::handle_100_continue_response. That
    requires the state machine to actually see the 100.

Observed

Client sends POST with Expect: 100-continue and a 14 byte body; origin speaks
HTTP/2. From traffic.out on the outbound session:

Send HEADERS frame flags: 0x4 length: 178          <- END_HEADERS only, no END_STREAM
change_state ... IDLE -> OPEN                       <- body withheld
Received HEADERS frame
received interim 1xx response from origin; awaiting final response
Received HEADERS frame                              <- final 200
Received DATA frame, flags: 1
change_state ... OPEN -> HALF_CLOSED_REMOTE

ATS advertised Content-Length: 14 to the origin and never sent a single DATA frame.
The stream never reaches HALF_CLOSED_LOCAL.

Two failure modes, depending on the origin:

Origin behavior Result
Responds without waiting for the body Body silently dropped; client receives 200 OK. No error logged.
Waits for the body after its 100, as an origin honoring Expect should Transaction hangs. curl reports Operation timed out after 15002 milliseconds with 0 bytes received; ATS eventually fails it at transaction_no_activity_timeout_out (30s default).

The first case is the more serious one: a POST reported to the client as successful
whose payload never reached the origin.

Note this diverges from the HTTP/1.x origin path, which routes the 1xx through
handle_100_continue_response and forwards it. Same client, same origin semantics,
different outcome based only on the origin's protocol.

Two fixes that do not work

Both of these were tried and made things worse, so the fix needs more than a carve-out:

  1. Excluding 100 from the discard. The client receives the 100, then
    502 Malformed Server Response Status, with recv headers malformed request logged.
    Nothing resets the receive header between decodes, so the final response decodes a
    second :status into the same header. This is the exact corruption Handle HTTP/2 1xx interim responses from the origin #13351 fixes.
  2. Calling send_headers() to propagate the interim, then resetting. Worse; nearly
    every interim case regresses.

So the outbound HTTP/2 path cannot currently deliver an interim response to the state
machine at all. Making 1xx work end to end (which would also let 103 Early Hints reach
the client per RFC 9110 15.2, as the HTTP/1 origin path already does) looks like the real
scope here.

Reproducer

Adds an expectwait mode to the interim-response gold test origin: send the 100, then
wait for a request-body DATA frame before the final 200.

--- a/tests/gold_tests/h2/h2_interim_origin.py
+++ b/tests/gold_tests/h2/h2_interim_origin.py
@@ def handle(sock: ssl.SSLSocket, mode: str) -> None:
                 if ftype == 0x1:  # a request HEADERS -> respond on the same stream
+                    if mode == "expectwait":
+                        # Honor Expect: 100-continue the way a correct origin does: send
+                        # the 100 and then wait for the body before the final response.
+                        sock.sendall(frame(0x1, 0x4, sid, interim_block("100")))
+                        continue
                     send_response(sock, mode, sid)
+                if ftype == 0x0 and mode == "expectwait":  # request body arrived
+                    sock.sendall(frame(0x1, 0x4, sid, final_block()))
+                    sock.sendall(frame(0x0, 0x1, sid, BODY))

Add expectwait to the --mode choices and to the test's mode list, then:

curl -v -s --max-time 15 -X POST --data-binary "payloadpayload" \
  -H "Expect: 100-continue" -H "Host: ats.test" \
  http://127.0.0.1:<ts_port>/expectwait

Expected: 200 with the response body. Actual: client timeout, no bytes received.

Pointing the same request at the existing continue mode origin instead shows the
silent-body-drop variant: 200 OK to the client, no DATA frame to the origin.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions