HTTP/2 origin: discarding 100 Continue strands the request body
- Dominant language
- C++
- Stars
- 2k
- Forks
- 874
- Avg merge
- 6d 15h
- Merged PRs (30d)
- 46
Description
## 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 #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`.
```diff
--- 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:/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.
Contributor guide
Assessment
This issue has not been assessed yet.