From defd0a8c5ab9efdecd637dfafca94338a135d505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 28 Sep 2026 14:19:06 +0200 Subject: [PATCH] Fail requests pipelined behind a 101 response After a 101 response the connection stayed open with the queued requests in place, so bytes arriving later, which belong to the protocol the server switched to (RFC 9110 15.2.2), were parsed as HTTP/1 responses to those requests. Queued requests now fail with the new :connection_upgraded error when the 101 response completes. Bytes arriving later are unexpected data as long as no new request is made on the connection. A failed request that was streaming its body can't stream the rest of it. :unprocessed isn't used because the pipelined request bytes reached the server as part of the new protocol, so the request might have been processed. The bytes after the 101 response in the same packet are still delivered as data for the upgrade request. --- lib/mint/http1.ex | 29 +++++++++++++++- test/mint/http1/conn_test.exs | 65 +++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index f83727ee..2fa3b28f 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -56,6 +56,11 @@ defmodule Mint.HTTP1 do `Mint.TransportError` with reason `:closed` instead, since the server might have processed them. + * `:connection_upgraded` - when a pipelined request gets no response because the + server answered a previous request with a `101` response. The connection then + uses another protocol, and the server might have received the request as part + of it. + * `{:unexpected_data, data}` - when unexpected data is received from the server. * `:invalid_status_line` - when the HTTP/1 status line is invalid. @@ -894,9 +899,12 @@ defmodule Mint.HTTP1 do decode(:status, conn, data, responses) end + # A 101 response switches the connection to another protocol (RFC 9110 + # 15.2.2), so the bytes after it belong to that protocol. defp decode_body(:single, conn, data, _request_ref, responses) do {conn, responses} = add_body(conn, data, responses) {conn, responses} = request_done(conn, responses) + {conn, responses} = fail_requests(conn, responses, wrap_error(:connection_upgraded)) {:ok, conn, responses} end @@ -1205,6 +1213,15 @@ defmodule Mint.HTTP1 do # Requests pipelined behind a response that closes the connection never get a # response of their own. defp close_after_response(conn, responses, error) do + {conn, responses} = fail_requests(conn, responses, error) + {internal_close(conn), responses} + end + + # Returns an error response with the given error for the current request and + # every queued one, and removes them from the connection. If one of them is + # streaming its body, the streaming state is cleared so the rest of the body + # can't be sent. + defp fail_requests(conn, responses, error) do requests = if conn.request, do: [conn.request | :queue.to_list(conn.requests)], else: [] responses = @@ -1212,7 +1229,12 @@ defmodule Mint.HTTP1 do [{:error, request.ref, error} | responses] end) - {internal_close(%{conn | request: nil, requests: :queue.new()}), responses} + # No request can be made while one is streaming its body, so a streaming + # request is the last one and it's among the failed ones unless it's done. + streaming_request = if requests == [], do: conn.streaming_request + + conn = %{conn | request: nil, requests: :queue.new(), streaming_request: streaming_request} + {conn, responses} end defp pop_request(conn) do @@ -1433,6 +1455,11 @@ defmodule Mint.HTTP1 do "\"connection: close\", so it's safe to retry on a new connection" end + def format_error(:connection_upgraded) do + "request got no response because the server switched the connection to another " <> + "protocol in response to a previous request" + end + def format_error(:request_body_is_streaming) do "a request body is currently streaming, so no new requests can be issued" end diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index 3b3414dd..d3d66d6f 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -1240,6 +1240,71 @@ defmodule Mint.HTTP1Test do assert conn.buffer == <<>> end + test "a request pipelined behind a successful CONNECT gets its response from the tunnel", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "CONNECT", "example.com:80", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + assert {:ok, conn, [{:status, ^ref1, 200}, {:headers, ^ref1, []}, {:done, ^ref1}]} = + HTTP1.stream( + conn, + {:tcp, conn.socket, "HTTP/1.1 200 Connection Established\r\n\r\n"} + ) + + response = "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok" + + assert {:ok, conn, + [{:status, ^ref2, 200}, {:headers, ^ref2, _}, {:data, ^ref2, "ok"}, {:done, ^ref2}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert HTTP1.open?(conn) + end + + test "requests pipelined behind a 101 response get no response", %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = "HTTP/1.1 101 Switching Protocols\r\nupgrade: foo\r\nconnection: upgrade\r\n\r\n" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 101}, + {:headers, ^ref1, _}, + {:done, ^ref1}, + {:error, ^ref2, %HTTPError{reason: :connection_upgraded}} + ] = responses + + assert HTTP1.open?(conn) + assert HTTP1.open_request_count(conn) == 0 + + data = "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok" + + assert {:error, _conn, %HTTPError{reason: {:unexpected_data, ^data}}, []} = + HTTP1.stream(conn, {:tcp, conn.socket, data}) + end + + test "a streaming request pipelined behind a 101 response can't stream its body", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "POST", "/", [], :stream) + + response = "HTTP/1.1 101 Switching Protocols\r\nupgrade: foo\r\nconnection: upgrade\r\n\r\n" + + assert {:ok, conn, + [ + {:status, ^ref1, 101}, + {:headers, ^ref1, _}, + {:done, ^ref1}, + {:error, ^ref2, %HTTPError{reason: :connection_upgraded}} + ]} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert {:error, _conn, %HTTPError{reason: :unknown_request_to_stream}} = + HTTP1.stream_request_body(conn, ref2, "hello") + + assert_raise ArgumentError, fn -> HTTP1.request_body_window(conn, ref2) end + end + test "unallowed trailer headers are removed from the trailer headers", %{conn: conn} do {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil)