From 09e0fc5b88917af9fd288020a957d875716b610f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 14 Sep 2026 00:38:02 +0200 Subject: [PATCH 1/8] Parse pipelined responses after bodiless HTTP/1 responses Responses without a body (HEAD, 204, 304, and 2xx to CONNECT) left the rest of the buffer untouched, so a pipelined response arriving in the same message was not delivered until further data arrived on the socket. --- .dialyzer_ignore | 2 +- lib/mint/http1.ex | 3 +-- test/mint/http1/conn_test.exs | 28 ++++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.dialyzer_ignore b/.dialyzer_ignore index 00387569..ae07a757 100644 --- a/.dialyzer_ignore +++ b/.dialyzer_ignore @@ -1,5 +1,5 @@ lib/mint/tunnel_proxy.ex:50 -lib/mint/http1.ex:1069 +lib/mint/http1.ex:1068 lib/mint/unsafe_proxy.ex:173 lib/mint/unsafe_proxy.ex:198 test/support diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index 96ca7c32..f6484456 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -816,10 +816,9 @@ defmodule Mint.HTTP1 do end defp decode_body(:none, conn, data, request_ref, responses) do - conn = put_in(conn.buffer, data) conn = request_done(conn) responses = [{:done, request_ref} | responses] - {:ok, conn, responses} + next_request(conn, data, responses) end # Informational (1xx) responses have no body and must not finalize the diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index 975c1a14..a230a695 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -373,6 +373,34 @@ defmodule Mint.HTTP1Test do refute HTTP1.open?(conn) end + test "pipelined response after a bodiless response in the same message", %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "HEAD", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref3} = HTTP1.request(conn, "GET", "/", [], nil) + + responses = + "HTTP/1.1 200 OK\r\ncontent-length: 5\r\n\r\n" <> + "HTTP/1.1 204 No Content\r\n\r\n" <> + "HTTP/1.1 200 OK\r\ncontent-length: 5\r\n\r\nXXXXX" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, responses}) + + assert [ + {:status, ^ref1, 200}, + {:headers, ^ref1, _}, + {:done, ^ref1}, + {:status, ^ref2, 204}, + {:headers, ^ref2, []}, + {:done, ^ref2}, + {:status, ^ref3, 200}, + {:headers, ^ref3, _}, + {:data, ^ref3, "XXXXX"}, + {:done, ^ref3} + ] = responses + + assert conn.buffer == "" + end + test "body with chunked transfer-encoding", %{conn: conn} do {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) From ffcfe25a147b17a71786855cffbe4095e06de433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 14 Sep 2026 01:17:19 +0200 Subject: [PATCH 2/8] Treat data after the last in-flight HTTP/1 response as an error Bytes left over once the response queue was empty were kept in the connection buffer and delivered as the response to whichever request was issued next, while the real response to that request was buffered in turn. Return {:unexpected_data, data} and close the connection instead, the same as when such bytes arrive in a separate message. Bytes after a 2xx response to CONNECT still stay buffered, since they belong to the tunnel. --- .dialyzer_ignore | 2 +- lib/mint/http1.ex | 25 ++++++++++++++++++--- test/mint/http1/conn_test.exs | 41 ++++++++++++++++++++++++++++------- 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/.dialyzer_ignore b/.dialyzer_ignore index ae07a757..db17d64c 100644 --- a/.dialyzer_ignore +++ b/.dialyzer_ignore @@ -1,5 +1,5 @@ lib/mint/tunnel_proxy.ex:50 -lib/mint/http1.ex:1068 +lib/mint/http1.ex:1087 lib/mint/unsafe_proxy.ex:173 lib/mint/unsafe_proxy.ex:198 test/support diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index f6484456..53d03e86 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -815,6 +815,21 @@ defmodule Mint.HTTP1 do end end + # A successful CONNECT switches the connection to tunnel mode, so bytes after + # the header section belong to the tunnel rather than to another response. + defp decode_body( + :none, + %{request: %{method: "CONNECT", status: status}} = conn, + data, + request_ref, + responses + ) + when status in 200..299 do + conn = request_done(conn) + responses = [{:done, request_ref} | responses] + {:ok, %{conn | buffer: data}, responses} + end + defp decode_body(:none, conn, data, request_ref, responses) do conn = request_done(conn) responses = [{:done, request_ref} | responses] @@ -1032,10 +1047,14 @@ defmodule Mint.HTTP1 do end end + defp next_request(%{request: nil} = conn, "", responses) do + {:ok, %{conn | buffer: ""}, responses} + end + + # Bytes left over after the last in-flight response would otherwise be + # delivered as the response to whichever request is issued next. defp next_request(%{request: nil} = conn, data, responses) do - # TODO: Figure out if we should keep buffering even though there are no - # requests in flight - {:ok, %{conn | buffer: data}, responses} + {:error, conn, wrap_error({:unexpected_data, data}), responses} end defp next_request(conn, data, responses) do diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index a230a695..4654519e 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -141,9 +141,9 @@ defmodule Mint.HTTP1Test do HTTP1.stream(conn, {:tcp, conn.socket, "012345678"}) assert {:ok, conn, [{:data, ^ref, "9"}, {:done, ^ref}]} = - HTTP1.stream(conn, {:tcp, conn.socket, "9XXX"}) + HTTP1.stream(conn, {:tcp, conn.socket, "9"}) - assert conn.buffer == "XXX" + assert conn.buffer == "" assert HTTP1.open?(conn) end @@ -151,9 +151,9 @@ defmodule Mint.HTTP1Test do {:ok, conn, ref} = HTTP1.request(conn, "HEAD", "/", [], nil) assert {:ok, conn, [_status, _headers, {:done, ^ref}]} = - HTTP1.stream(conn, {:tcp, conn.socket, "HTTP/1.1 200 OK\r\n\r\nXXX"}) + HTTP1.stream(conn, {:tcp, conn.socket, "HTTP/1.1 200 OK\r\n\r\n"}) - assert conn.buffer == "XXX" + assert conn.buffer == "" end test "no body in 2xx response to CONNECT request", %{conn: conn} do @@ -217,6 +217,31 @@ defmodule Mint.HTTP1Test do assert_closed_and_released(conn) end + test "data after the last in-flight response is an error", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) + response = "HTTP/1.1 200 OK\r\ncontent-length: 1\r\n\r\nX" + extra = "HTTP/1.1 404 Not Found\r\ncontent-length: 1\r\n\r\nY" + + assert {:error, conn, %HTTPError{reason: {:unexpected_data, ^extra}}, responses} = + HTTP1.stream(conn, {:tcp, conn.socket, response <> extra}) + + assert [{:status, ^ref, 200}, {:headers, ^ref, _}, {:data, ^ref, "X"}, {:done, ^ref}] = + responses + + assert_closed_and_released(conn) + end + + test "data after the last in-flight bodiless response is an error", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "HEAD", "/", [], nil) + response = "HTTP/1.1 200 OK\r\ncontent-length: 1\r\n\r\n" + + assert {:error, conn, %HTTPError{reason: {:unexpected_data, "X"}}, responses} = + HTTP1.stream(conn, {:tcp, conn.socket, response <> "X"}) + + assert [{:status, ^ref, 200}, {:headers, ^ref, _}, {:done, ^ref}] = responses + assert_closed_and_released(conn) + end + test "responses before an error are returned in order", %{conn: conn} do {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) response = "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n5\r\nhello\r\nXX" @@ -406,7 +431,7 @@ defmodule Mint.HTTP1Test do response = "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n" <> - "2\r\n01\r\n2\r\n23\r\n0\r\n\r\nXXX" + "2\r\n01\r\n2\r\n23\r\n0\r\n\r\n" assert {:ok, conn, [status, headers, data1, data2, done]} = HTTP1.stream(conn, {:tcp, conn.socket, response}) @@ -417,7 +442,7 @@ defmodule Mint.HTTP1Test do assert data2 == {:data, ref, "23"} assert done == {:done, ref} - assert conn.buffer == "XXX" + assert conn.buffer == "" end for chunk_size <- ["+5", "+0", "-0"] do @@ -608,7 +633,7 @@ defmodule Mint.HTTP1Test do response = "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n" <> - "2;meta\r\n01\r\n2\r\n23\r\n0;meta\r\nmy-trailer: value\r\n\r\nXXX" + "2;meta\r\n01\r\n2\r\n23\r\n0;meta\r\nmy-trailer: value\r\n\r\n" assert {:ok, conn, [status, headers, data1, data2, trailers, done]} = HTTP1.stream(conn, {:tcp, conn.socket, response}) @@ -620,7 +645,7 @@ defmodule Mint.HTTP1Test do assert trailers == {:headers, ref, [{"my-trailer", "value"}]} assert done == {:done, ref} - assert conn.buffer == "XXX" + assert conn.buffer == "" end test "limits the size of a chunked trailer section", %{port: port} do From acc46d145a3c2ad8dc59588ea030f24b627f56f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 14 Sep 2026 01:18:39 +0200 Subject: [PATCH 3/8] Parse responses that arrive while an HTTP/1 request body is streaming A request with a :stream body was only added to the response queue when the caller sent :eof, so a response the server sent before that, such as 100 Continue or an early 413, hit handle_data/2 with no request in flight and closed the connection with {:unexpected_data, _}. Enqueue the request when its headers are sent and track only the ref and body encoding in streaming_request. --- .dialyzer_ignore | 2 +- lib/mint/http1.ex | 66 +++++++++++++---------------------- test/mint/http1/conn_test.exs | 42 ++++++++++++++++++++++ 3 files changed, 67 insertions(+), 43 deletions(-) diff --git a/.dialyzer_ignore b/.dialyzer_ignore index db17d64c..126011ed 100644 --- a/.dialyzer_ignore +++ b/.dialyzer_ignore @@ -1,5 +1,5 @@ lib/mint/tunnel_proxy.ex:50 -lib/mint/http1.ex:1087 +lib/mint/http1.ex:1076 lib/mint/unsafe_proxy.ex:173 lib/mint/unsafe_proxy.ex:198 test/support diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index 53d03e86..786fb9af 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -350,17 +350,19 @@ defmodule Mint.HTTP1 do ), :ok <- transport.send(socket, iodata) do request_ref = make_ref() - request = new_request(request_ref, method, body, encoding) - - case request.state do - {:stream_request, _} -> - conn = %{conn | streaming_request: request} - {:ok, conn, request_ref} + conn = enqueue_request(conn, new_request(request_ref, method)) + + # The request is enqueued right away so that a response the server + # sends before the body is complete (such as 100 Continue or an early + # 413) is parsed rather than treated as unexpected data. + conn = + if body == :stream do + %{conn | streaming_request: %{ref: request_ref, encoding: encoding}} + else + conn + end - _ -> - conn = enqueue_request(conn, request) - {:ok, conn, request_ref} - end + {:ok, conn, request_ref} else {:error, %TransportError{reason: :closed} = error} -> conn = internal_close(conn) @@ -406,17 +408,15 @@ defmodule Mint.HTTP1 do ) :: {:ok, t()} | {:error, t(), Types.error()} def stream_request_body( - %__MODULE__{streaming_request: %{state: {:stream_request, :identity}, ref: ref}} = conn, + %__MODULE__{streaming_request: %{encoding: :identity, ref: ref}} = conn, ref, :eof ) do - request = %{conn.streaming_request | state: :status} - conn = enqueue_request(%{conn | streaming_request: nil}, request) - {:ok, conn} + {:ok, %{conn | streaming_request: nil}} end def stream_request_body( - %__MODULE__{streaming_request: %{state: {:stream_request, :identity}, ref: ref}} = conn, + %__MODULE__{streaming_request: %{encoding: :identity, ref: ref}} = conn, ref, {:eof, _trailer_headers} ) do @@ -424,7 +424,7 @@ defmodule Mint.HTTP1 do end def stream_request_body( - %__MODULE__{streaming_request: %{state: {:stream_request, :identity}, ref: ref}} = conn, + %__MODULE__{streaming_request: %{encoding: :identity, ref: ref}} = conn, ref, body ) do @@ -442,25 +442,16 @@ defmodule Mint.HTTP1 do end def stream_request_body( - %__MODULE__{streaming_request: %{state: {:stream_request, :chunked}, ref: ref}} = conn, + %__MODULE__{streaming_request: %{encoding: :chunked, ref: ref}} = conn, ref, chunk ) do with {:ok, chunk} <- validate_chunk(conn, chunk), :ok <- conn.transport.send(conn.socket, Request.encode_chunk(chunk)) do case chunk do - :eof -> - request = %{conn.streaming_request | state: :status} - conn = enqueue_request(%{conn | streaming_request: nil}, request) - {:ok, conn} - - {:eof, _trailer_headers} -> - request = %{conn.streaming_request | state: :status} - conn = enqueue_request(%{conn | streaming_request: nil}, request) - {:ok, conn} - - _other -> - {:ok, conn} + :eof -> {:ok, %{conn | streaming_request: nil}} + {:eof, _trailer_headers} -> {:ok, %{conn | streaming_request: nil}} + _other -> {:ok, conn} end else :empty_chunk -> @@ -635,10 +626,8 @@ defmodule Mint.HTTP1 do @spec open_request_count(t()) :: non_neg_integer() def open_request_count(%__MODULE__{} = conn) do case conn do - %{request: nil, streaming_request: nil} -> 0 - %{request: nil} -> 1 - %{streaming_request: nil} -> 1 + :queue.len(conn.requests) - _ -> 2 + :queue.len(conn.requests) + %{request: nil} -> 0 + _ -> 1 + :queue.len(conn.requests) end end @@ -1272,17 +1261,10 @@ defmodule Mint.HTTP1 do :ok end - defp new_request(ref, method, body, encoding) do - state = - if body == :stream do - {:stream_request, encoding} - else - :status - end - + defp new_request(ref, method) do %{ ref: ref, - state: state, + state: :status, method: method, version: nil, status: nil, diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index 4654519e..7b999eb8 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -1269,6 +1269,48 @@ defmodule Mint.HTTP1Test do end describe "streaming requests" do + test "response arriving before the request body is complete", + %{conn: conn, server_socket: server_socket} do + {:ok, conn, ref} = HTTP1.request(conn, "POST", "/", [{"content-length", "10"}], :stream) + _ = receive_request_string(server_socket) + + {:ok, conn} = HTTP1.stream_request_body(conn, ref, "hello") + assert receive_request_string(server_socket) == "hello" + + response = "HTTP/1.1 413 Payload Too Large\r\ncontent-length: 0\r\n\r\n" + + assert {:ok, conn, [{:status, ^ref, 413}, {:headers, ^ref, _}, {:done, ^ref}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert HTTP1.open?(conn) + assert HTTP1.open_request_count(conn) == 0 + + assert {:error, conn, %HTTPError{reason: :request_body_is_streaming}} = + HTTP1.request(conn, "GET", "/", [], nil) + + {:ok, conn} = HTTP1.stream_request_body(conn, ref, :eof) + assert {:ok, _conn, _ref} = HTTP1.request(conn, "GET", "/", [], nil) + end + + test "100 Continue before the request body is streamed", + %{conn: conn, server_socket: server_socket} do + headers = [{"expect", "100-continue"}, {"content-length", "5"}] + {:ok, conn, ref} = HTTP1.request(conn, "POST", "/", headers, :stream) + _ = receive_request_string(server_socket) + + assert {:ok, conn, [{:status, ^ref, 100}, {:headers, ^ref, []}]} = + HTTP1.stream(conn, {:tcp, conn.socket, "HTTP/1.1 100 Continue\r\n\r\n"}) + + {:ok, conn} = HTTP1.stream_request_body(conn, ref, "hello") + assert receive_request_string(server_socket) == "hello" + {:ok, conn} = HTTP1.stream_request_body(conn, ref, :eof) + + response = "HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n" + + assert {:ok, _conn, [{:status, ^ref, 200}, {:headers, ^ref, _}, {:done, ^ref}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + end + test "transfer-encoding is set to chunked if not set already, and content is chunked", %{conn: conn, server_socket: server_socket, port: port} do {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], :stream) From cf04711a046e3f3ab25f2610099d8636b237a59f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sat, 19 Sep 2026 19:31:24 +0200 Subject: [PATCH 4/8] Fail HTTP/1 requests pipelined behind a response that closes the connection A response with "Connection: close", or an HTTP/1.0 response without "keep-alive", closes the connection once it completes. Requests already pipelined behind it stayed queued and never got a response or an error, so a caller waiting on them only found out by checking open?/1. They now fail with an :unprocessed error, matching what HTTP/2 reports for streams the server didn't process before a GOAWAY. --- .dialyzer_ignore | 2 +- lib/mint/http1.ex | 27 +++++++++++++++++++++++ test/mint/http1/conn_test.exs | 41 +++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/.dialyzer_ignore b/.dialyzer_ignore index 126011ed..c3cec7ff 100644 --- a/.dialyzer_ignore +++ b/.dialyzer_ignore @@ -1,5 +1,5 @@ lib/mint/tunnel_proxy.ex:50 -lib/mint/http1.ex:1076 +lib/mint/http1.ex:1098 lib/mint/unsafe_proxy.ex:173 lib/mint/unsafe_proxy.ex:198 test/support diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index 786fb9af..59a92827 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -43,6 +43,11 @@ defmodule Mint.HTTP1 do * `:request_body_is_streaming` - when you call `request/5` to send a new request but another request is already streaming. + * `:unprocessed` - when a pipelined request gets no response because the server + closed the connection after a previous response, either with a `connection: close` + header or by answering with HTTP/1.0 without `connection: keep-alive`. The request + can be retried on a new connection. + * `{:unexpected_data, data}` - when unexpected data is received from the server. * `:invalid_status_line` - when the HTTP/1 status line is invalid. @@ -537,6 +542,7 @@ defmodule Mint.HTTP1 do case decode(request.state, conn, data, []) do {:ok, conn, responses} -> + {conn, responses} = fail_queued_requests_if_closed(conn, responses) {:ok, conn, Enum.reverse(responses)} {:error, conn, reason, responses} -> @@ -545,6 +551,22 @@ defmodule Mint.HTTP1 do end end + # A response with "Connection: close" (or an HTTP/1.0 response without + # "keep-alive") closes the connection, so pipelined requests queued behind it + # will never get a response. + defp fail_queued_requests_if_closed(%{state: :closed} = conn, responses) do + requests = if conn.request, do: [conn.request | :queue.to_list(conn.requests)], else: [] + + responses = + Enum.reduce(requests, responses, fn request, responses -> + [{:error, request.ref, wrap_error(:unprocessed)} | responses] + end) + + {%{conn | request: nil, requests: :queue.new()}, responses} + end + + defp fail_queued_requests_if_closed(conn, responses), do: {conn, responses} + defp handle_close(%__MODULE__{request: request} = conn) do conn = internal_close(conn) conn = request_done(conn) @@ -1344,6 +1366,11 @@ defmodule Mint.HTTP1 do "the connection is closed" end + def format_error(:unprocessed) do + "request was not processed because the server closed the connection after a " <> + "previous response, so it's safe to retry on a new connection" + 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 7b999eb8..9971861c 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -1545,6 +1545,47 @@ defmodule Mint.HTTP1Test do end end + test "pipelined requests behind a Connection: close response get an :unprocessed error", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref3} = HTTP1.request(conn, "GET", "/", [], nil) + + response = "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 2\r\n\r\nhi" + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 200}, + {:headers, ^ref1, [{"connection", "close"}, {"content-length", "2"}]}, + {:data, ^ref1, "hi"}, + {:done, ^ref1}, + {:error, ^ref2, %HTTPError{reason: :unprocessed}}, + {:error, ^ref3, %HTTPError{reason: :unprocessed}} + ] = responses + + assert HTTP1.open_request_count(conn) == 0 + assert_closed_and_released(conn) + end + + test "pipelined requests behind an HTTP/1.0 response get an :unprocessed error", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = "HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nhi" + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 200}, + {:headers, ^ref1, _}, + {:data, ^ref1, "hi"}, + {:done, ^ref1}, + {:error, ^ref2, %HTTPError{reason: :unprocessed}} + ] = responses + + assert_closed_and_released(conn) + end + defp request_string(string) do String.replace(string, "\n", "\r\n") end From ab22cd4ed7aaf532238112e908b5265e13bd8ebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sat, 19 Sep 2026 19:51:59 +0200 Subject: [PATCH 5/8] Fail HTTP/1 requests pipelined behind a close-delimited response When a response has neither Content-Length nor Transfer-Encoding its body ends when the server closes the connection. Requests that were pipelined behind such a response got no response at all: the close produced only the {:done, ref} for the in-flight request and the queued requests stayed in the connection with open_request_count above zero. They now get the same {:error, ref, :unprocessed} that queued requests behind a "Connection: close" response get. --- .dialyzer_ignore | 2 +- lib/mint/http1.ex | 3 ++- test/mint/http1/conn_test.exs | 23 +++++++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.dialyzer_ignore b/.dialyzer_ignore index c3cec7ff..36ce4727 100644 --- a/.dialyzer_ignore +++ b/.dialyzer_ignore @@ -1,5 +1,5 @@ lib/mint/tunnel_proxy.ex:50 -lib/mint/http1.ex:1098 +lib/mint/http1.ex:1099 lib/mint/unsafe_proxy.ex:173 lib/mint/unsafe_proxy.ex:198 test/support diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index 59a92827..164d4406 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -573,7 +573,8 @@ defmodule Mint.HTTP1 do if request && request.body == :until_closed do conn = put_in(conn.state, :closed) - {:ok, conn, [{:done, request.ref}]} + {conn, responses} = fail_queued_requests_if_closed(conn, [{:done, request.ref}]) + {:ok, conn, Enum.reverse(responses)} else {:error, conn, conn.transport.wrap_error(:closed), []} end diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index 9971861c..08598971 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -1586,6 +1586,29 @@ defmodule Mint.HTTP1Test do assert_closed_and_released(conn) end + test "pipelined requests behind a close-delimited response get an :unprocessed error", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref3} = HTTP1.request(conn, "GET", "/", [], nil) + + response = "HTTP/1.1 200 OK\r\n\r\nhi" + + assert {:ok, conn, [{:status, ^ref1, 200}, {:headers, ^ref1, []}, {:data, ^ref1, "hi"}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp_closed, conn.socket}) + + assert [ + {:done, ^ref1}, + {:error, ^ref2, %HTTPError{reason: :unprocessed}}, + {:error, ^ref3, %HTTPError{reason: :unprocessed}} + ] = responses + + assert HTTP1.open_request_count(conn) == 0 + assert_closed_and_released(conn) + end + defp request_string(string) do String.replace(string, "\n", "\r\n") end From 428f8e05b5e90f6dff72d82771b5c8c759376fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sat, 19 Sep 2026 21:00:32 +0200 Subject: [PATCH 6/8] Return errors from HTTP/1 stream_request_body/3 for requests that are not streaming stream_request_body/3 only had clauses for the request currently streaming its body, so calling it with any other request ref, with a ref whose body already ended with :eof, or with an unknown ref raised FunctionClauseError instead of returning an error tuple. It now returns :closed on a closed connection, :request_is_not_streaming for a request of this connection that is not streaming, and :unknown_request_to_stream for a ref that doesn't belong to the connection, matching HTTP/2. --- .dialyzer_ignore | 2 +- lib/mint/http1.ex | 31 +++++++++++++++++++++++++++++++ test/mint/http1/conn_test.exs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/.dialyzer_ignore b/.dialyzer_ignore index 36ce4727..f3092827 100644 --- a/.dialyzer_ignore +++ b/.dialyzer_ignore @@ -1,5 +1,5 @@ lib/mint/tunnel_proxy.ex:50 -lib/mint/http1.ex:1099 +lib/mint/http1.ex:1122 lib/mint/unsafe_proxy.ex:173 lib/mint/unsafe_proxy.ex:198 test/support diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index 164d4406..4fb1aadc 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -43,6 +43,12 @@ defmodule Mint.HTTP1 do * `:request_body_is_streaming` - when you call `request/5` to send a new request but another request is already streaming. + * `:request_is_not_streaming` - when you call `stream_request_body/3` for a + request whose body is not being streamed, for example after sending `:eof`. + + * `:unknown_request_to_stream` - when you call `stream_request_body/3` with a + request reference that doesn't belong to this connection. + * `:unprocessed` - when a pipelined request gets no response because the server closed the connection after a previous response, either with a `connection: close` header or by answering with HTTP/1.0 without `connection: keep-alive`. The request @@ -412,6 +418,10 @@ defmodule Mint.HTTP1 do iodata() | :eof | {:eof, trailer_headers :: Types.headers()} ) :: {:ok, t()} | {:error, t(), Types.error()} + def stream_request_body(%__MODULE__{state: :closed} = conn, _request_ref, _chunk) do + {:error, conn, wrap_error(:closed)} + end + def stream_request_body( %__MODULE__{streaming_request: %{encoding: :identity, ref: ref}} = conn, ref, @@ -471,6 +481,19 @@ defmodule Mint.HTTP1 do end end + def stream_request_body(%__MODULE__{} = conn, request_ref, _chunk) + when is_reference(request_ref) do + known? = + (conn.request != nil and conn.request.ref == request_ref) or + Enum.any?(:queue.to_list(conn.requests), &(&1.ref == request_ref)) + + if known? do + {:error, conn, wrap_error(:request_is_not_streaming)} + else + {:error, conn, wrap_error(:unknown_request_to_stream)} + end + end + defp validate_chunk(conn, {:eof, trailers}) do trailers = Headers.from_raw(trailers) @@ -1376,6 +1399,14 @@ defmodule Mint.HTTP1 do "a request body is currently streaming, so no new requests can be issued" end + def format_error(:request_is_not_streaming) do + "can't send more data on a request that is not streaming its body" + end + + def format_error(:unknown_request_to_stream) do + "can't stream the request body because the request is not known to this connection" + end + def format_error({:unexpected_data, data}) do "received unexpected data: " <> inspect(data) end diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index 08598971..a58dd7ab 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -1269,6 +1269,37 @@ defmodule Mint.HTTP1Test do end describe "streaming requests" do + test "streaming a body for a request that is not streaming returns an error", + %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) + + assert {:error, conn, %HTTPError{reason: :request_is_not_streaming}} = + HTTP1.stream_request_body(conn, ref, "hello") + + assert {:error, conn, %HTTPError{reason: :unknown_request_to_stream}} = + HTTP1.stream_request_body(conn, make_ref(), "hello") + + assert HTTP1.open?(conn) + end + + test "streaming a body after :eof returns an error", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "POST", "/", [], :stream) + {:ok, conn} = HTTP1.stream_request_body(conn, ref, :eof) + + assert {:error, conn, %HTTPError{reason: :request_is_not_streaming}} = + HTTP1.stream_request_body(conn, ref, "hello") + + assert HTTP1.open?(conn) + end + + test "streaming a body on a closed connection returns an error", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "POST", "/", [], :stream) + {:ok, conn} = HTTP1.close(conn) + + assert {:error, _conn, %HTTPError{reason: :closed}} = + HTTP1.stream_request_body(conn, ref, "hello") + end + test "response arriving before the request body is complete", %{conn: conn, server_socket: server_socket} do {:ok, conn, ref} = HTTP1.request(conn, "POST", "/", [{"content-length", "10"}], :stream) From 46b65c06c793bb026a172af60620d090cd709a12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 24 Sep 2026 12:41:55 +0200 Subject: [PATCH 7/8] Stop parsing HTTP/1 responses after one that closes the connection After a response with "Connection: close" (or an HTTP/1.0 response without "keep-alive") the connection was closed, but bytes that followed it in the same message were still decoded as the response to the next queued request. A complete one was delivered as that request's response, and a partial one produced a status for the request followed by an :unprocessed error. The server sends nothing after such a response (RFC 9112 section 9.6), so the remaining bytes are now dropped and the queued requests fail. --- .dialyzer_ignore | 2 +- lib/mint/http1.ex | 6 +++++ test/mint/http1/conn_test.exs | 45 +++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/.dialyzer_ignore b/.dialyzer_ignore index f3092827..f0d86ee9 100644 --- a/.dialyzer_ignore +++ b/.dialyzer_ignore @@ -1,5 +1,5 @@ lib/mint/tunnel_proxy.ex:50 -lib/mint/http1.ex:1122 +lib/mint/http1.ex:1128 lib/mint/unsafe_proxy.ex:173 lib/mint/unsafe_proxy.ex:198 test/support diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index 4fb1aadc..40af9048 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -1082,6 +1082,12 @@ defmodule Mint.HTTP1 do end end + # A response that closes the connection is the last one the server sends on + # it, so anything after it can't be a response to a queued request. + defp next_request(%{state: :closed} = conn, _data, responses) do + {:ok, %{conn | buffer: ""}, responses} + end + defp next_request(%{request: nil} = conn, "", responses) do {:ok, %{conn | buffer: ""}, responses} end diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index a58dd7ab..5b06f0ca 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -1598,6 +1598,51 @@ defmodule Mint.HTTP1Test do assert_closed_and_released(conn) end + test "bytes after a bodiless response that closes the connection are not parsed", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "HEAD", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n" <> + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 200}, + {:headers, ^ref1, [{"connection", "close"}]}, + {:done, ^ref1}, + {:error, ^ref2, %HTTPError{reason: :unprocessed}} + ] = responses + + assert conn.buffer == "" + assert_closed_and_released(conn) + end + + test "bytes after a response body that closes the connection are not parsed", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 2\r\n\r\nhi" <> + "HTTP/1.1 200 OK\r\n" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 200}, + {:headers, ^ref1, _}, + {:data, ^ref1, "hi"}, + {:done, ^ref1}, + {:error, ^ref2, %HTTPError{reason: :unprocessed}} + ] = responses + + assert conn.buffer == "" + assert_closed_and_released(conn) + end + test "pipelined requests behind an HTTP/1.0 response get an :unprocessed error", %{conn: conn} do {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) From 1ea02f7d23d12472eefacc23d4188e95208094a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 24 Sep 2026 12:43:15 +0200 Subject: [PATCH 8/8] Fail HTTP/1 requests behind HTTP/1.0 and close-delimited responses as :closed Requests pipelined behind a response that closes the connection all got an :unprocessed error, which says they're safe to retry. That only holds for a "Connection: close" response, since the server must not process any request after it (RFC 9112 section 9.6). A server that answers with HTTP/1.0 without "keep-alive", or delimits the body by closing the connection, might have processed the requests behind it already, and a non-idempotent one mustn't be retried automatically (RFC 9112 section 9.3.1). Those requests now get a TransportError with reason :closed. --- .dialyzer_ignore | 2 +- lib/mint/http1.ex | 113 +++++++++++++++++----------------- test/mint/http1/conn_test.exs | 10 +-- 3 files changed, 62 insertions(+), 63 deletions(-) diff --git a/.dialyzer_ignore b/.dialyzer_ignore index f0d86ee9..dc446208 100644 --- a/.dialyzer_ignore +++ b/.dialyzer_ignore @@ -1,5 +1,5 @@ lib/mint/tunnel_proxy.ex:50 -lib/mint/http1.ex:1128 +lib/mint/http1.ex:1104 lib/mint/unsafe_proxy.ex:173 lib/mint/unsafe_proxy.ex:198 test/support diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index 40af9048..aa5790d7 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -50,9 +50,11 @@ defmodule Mint.HTTP1 do request reference that doesn't belong to this connection. * `:unprocessed` - when a pipelined request gets no response because the server - closed the connection after a previous response, either with a `connection: close` - header or by answering with HTTP/1.0 without `connection: keep-alive`. The request - can be retried on a new connection. + answered a previous request with a `connection: close` header. The server didn't + process the request, so it can be retried on a new connection. Requests pipelined + behind a response that closes the connection in other ways get a + `Mint.TransportError` with reason `:closed` instead, since the server might have + processed them. * `{:unexpected_data, data}` - when unexpected data is received from the server. @@ -565,7 +567,6 @@ defmodule Mint.HTTP1 do case decode(request.state, conn, data, []) do {:ok, conn, responses} -> - {conn, responses} = fail_queued_requests_if_closed(conn, responses) {:ok, conn, Enum.reverse(responses)} {:error, conn, reason, responses} -> @@ -574,33 +575,16 @@ defmodule Mint.HTTP1 do end end - # A response with "Connection: close" (or an HTTP/1.0 response without - # "keep-alive") closes the connection, so pipelined requests queued behind it - # will never get a response. - defp fail_queued_requests_if_closed(%{state: :closed} = conn, responses) do - requests = if conn.request, do: [conn.request | :queue.to_list(conn.requests)], else: [] - - responses = - Enum.reduce(requests, responses, fn request, responses -> - [{:error, request.ref, wrap_error(:unprocessed)} | responses] - end) - - {%{conn | request: nil, requests: :queue.new()}, responses} + defp handle_close(%__MODULE__{request: %{body: :until_closed} = request} = conn) do + conn = pop_request(conn) + responses = [{:done, request.ref}] + {conn, responses} = close_after_response(conn, responses, conn.transport.wrap_error(:closed)) + {:ok, conn, Enum.reverse(responses)} end - defp fail_queued_requests_if_closed(conn, responses), do: {conn, responses} - - defp handle_close(%__MODULE__{request: request} = conn) do - conn = internal_close(conn) - conn = request_done(conn) - - if request && request.body == :until_closed do - conn = put_in(conn.state, :closed) - {conn, responses} = fail_queued_requests_if_closed(conn, [{:done, request.ref}]) - {:ok, conn, Enum.reverse(responses)} - else - {:error, conn, conn.transport.wrap_error(:closed), []} - end + defp handle_close(conn) do + conn = conn |> internal_close() |> pop_request() + {:error, conn, conn.transport.wrap_error(:closed), []} end defp handle_transport_error(conn, error) do @@ -856,18 +840,16 @@ defmodule Mint.HTTP1 do :none, %{request: %{method: "CONNECT", status: status}} = conn, data, - request_ref, + _request_ref, responses ) when status in 200..299 do - conn = request_done(conn) - responses = [{:done, request_ref} | responses] + {conn, responses} = request_done(conn, responses) {:ok, %{conn | buffer: data}, responses} end - defp decode_body(:none, conn, data, request_ref, responses) do - conn = request_done(conn) - responses = [{:done, request_ref} | responses] + defp decode_body(:none, conn, data, _request_ref, responses) do + {conn, responses} = request_done(conn, responses) next_request(conn, data, responses) end @@ -893,10 +875,9 @@ defmodule Mint.HTTP1 do decode(:status, conn, data, responses) end - defp decode_body(:single, conn, data, request_ref, responses) do + defp decode_body(:single, conn, data, _request_ref, responses) do {conn, responses} = add_body(conn, data, responses) - conn = request_done(conn) - responses = [{:done, request_ref} | responses] + {conn, responses} = request_done(conn, responses) {:ok, conn, responses} end @@ -905,7 +886,7 @@ defmodule Mint.HTTP1 do {:ok, conn, responses} end - defp decode_body({:content_length, length}, conn, data, request_ref, responses) do + defp decode_body({:content_length, length}, conn, data, _request_ref, responses) do cond do length > byte_size(data) -> conn = put_in(conn.request.body, {:content_length, length - byte_size(data)}) @@ -915,8 +896,7 @@ defmodule Mint.HTTP1 do length <= byte_size(data) -> {body, rest} = :erlang.split_binary(data, length) {conn, responses} = add_body(conn, body, responses) - conn = request_done(conn) - responses = [{:done, request_ref} | responses] + {conn, responses} = request_done(conn, responses) next_request(conn, rest, responses) end end @@ -1015,12 +995,8 @@ defmodule Mint.HTTP1 do {:ok, _request} -> headers = Headers.remove_unallowed_trailer(headers) - responses = [ - {:done, conn.request.ref} - | add_trailer_headers(headers, conn.request.ref, responses) - ] - - conn = request_done(conn) + responses = add_trailer_headers(headers, conn.request.ref, responses) + {conn, responses} = request_done(conn, responses) next_request(conn, rest, responses) {:error, reason} -> @@ -1196,23 +1172,46 @@ defmodule Mint.HTTP1 do # lifetime of the underlying socket. In particular, HTTP/1.0 responses are # otherwise treated as non-persistent and would close the newly-established # tunnel before the caller can use it. - defp request_done(%{request: %{method: "CONNECT", status: status}} = conn) + defp request_done(%{request: %{method: "CONNECT", status: status} = request} = conn, responses) when status in 200..299 do - pop_request(conn) + {pop_request(conn), [{:done, request.ref} | responses]} end - defp request_done(%{request: request} = conn) do + defp request_done(%{request: request} = conn, responses) do conn = pop_request(conn) + responses = [{:done, request.ref} | responses] cond do - !request -> conn - "close" in request.connection -> internal_close(conn) - request.version >= {1, 1} -> conn - "keep-alive" in request.connection -> conn - true -> internal_close(conn) + # The server doesn't process any requests after one it answers with + # "Connection: close" (RFC 9112 section 9.6), so the queued requests can + # be retried. + "close" in request.connection -> + close_after_response(conn, responses, wrap_error(:unprocessed)) + + request.version >= {1, 1} -> + {conn, responses} + + "keep-alive" in request.connection -> + {conn, responses} + + true -> + close_after_response(conn, responses, conn.transport.wrap_error(:closed)) end end + # Requests pipelined behind a response that closes the connection never get a + # response of their own. + defp close_after_response(conn, responses, error) do + requests = if conn.request, do: [conn.request | :queue.to_list(conn.requests)], else: [] + + responses = + Enum.reduce(requests, responses, fn request, responses -> + [{:error, request.ref, error} | responses] + end) + + {internal_close(%{conn | request: nil, requests: :queue.new()}), responses} + end + defp pop_request(conn) do case :queue.out(conn.requests) do {{:value, request}, requests} -> @@ -1397,8 +1396,8 @@ defmodule Mint.HTTP1 do end def format_error(:unprocessed) do - "request was not processed because the server closed the connection after a " <> - "previous response, so it's safe to retry on a new connection" + "request was not processed because the server answered a previous request with " <> + "\"connection: close\", so it's safe to retry on a new connection" end def format_error(:request_body_is_streaming) do diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index 5b06f0ca..376378e4 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -1643,7 +1643,7 @@ defmodule Mint.HTTP1Test do assert_closed_and_released(conn) end - test "pipelined requests behind an HTTP/1.0 response get an :unprocessed error", + test "pipelined requests behind an HTTP/1.0 response get a :closed error", %{conn: conn} do {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) @@ -1656,13 +1656,13 @@ defmodule Mint.HTTP1Test do {:headers, ^ref1, _}, {:data, ^ref1, "hi"}, {:done, ^ref1}, - {:error, ^ref2, %HTTPError{reason: :unprocessed}} + {:error, ^ref2, %TransportError{reason: :closed}} ] = responses assert_closed_and_released(conn) end - test "pipelined requests behind a close-delimited response get an :unprocessed error", + test "pipelined requests behind a close-delimited response get a :closed error", %{conn: conn} do {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) @@ -1677,8 +1677,8 @@ defmodule Mint.HTTP1Test do assert [ {:done, ^ref1}, - {:error, ^ref2, %HTTPError{reason: :unprocessed}}, - {:error, ^ref3, %HTTPError{reason: :unprocessed}} + {:error, ^ref2, %TransportError{reason: :closed}}, + {:error, ^ref3, %TransportError{reason: :closed}} ] = responses assert HTTP1.open_request_count(conn) == 0