From 10ef11e43bb2b364febf85d5b49268fe6e47c127 Mon Sep 17 00:00:00 2001 From: Pradeep Kumar Date: Sun, 19 Oct 2025 16:42:04 +0530 Subject: [PATCH 1/4] Introduce new HTTP/1 option for status reason-phrase --- lib/mint/http1.ex | 29 +++++++++++++++++++++++++---- test/mint/http1/conn_test.exs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index 8c05e2cf..e337d5db 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -99,7 +99,8 @@ defmodule Mint.HTTP1 do buffer: "", proxy_headers: [], private: %{}, - log: false + log: false, + optional_responses: [] ] defmacrop log(conn, level, message) do @@ -128,6 +129,11 @@ defmodule Mint.HTTP1 do will not be validated. You might want this if you deal with non standard- conforming URIs but need to preserve them. The default is to validate the request target. *Available since v1.7.0*. + * `:optional_responses` - (list of atoms) a list of optional responses to return. + The possible values in the list are `:status_reason` which will return the + [reason-phrase](https://datatracker.ietf.org/doc/html/rfc9112#name-status-line) + for the status code, if it is returned by the server in status-line. + This is only available for HTTP/1.1 connections. *Available since v1.7.2*. """ @spec connect(Types.scheme(), Types.address(), :inet.port_number(), keyword()) :: @@ -206,7 +212,8 @@ defmodule Mint.HTTP1 do state: :open, log: log?, case_sensitive_headers: Keyword.get(opts, :case_sensitive_headers, false), - skip_target_validation: Keyword.get(opts, :skip_target_validation, false) + skip_target_validation: Keyword.get(opts, :skip_target_validation, false), + optional_responses: Keyword.get(opts, :optional_responses, []) } {:ok, conn} @@ -646,10 +653,10 @@ defmodule Mint.HTTP1 do defp decode(:status, %{request: request} = conn, data, responses) do case Response.decode_status_line(data) do - {:ok, {version, status, _reason}, rest} -> + {:ok, {version, status, _reason} = status_line, rest} -> request = %{request | version: version, status: status, state: :headers} conn = %{conn | request: request} - responses = [{:status, request.ref, status} | responses] + responses = put_status_responses(conn, status_line, responses) decode(:headers, conn, rest, responses) :more -> @@ -872,6 +879,20 @@ defmodule Mint.HTTP1 do end end + defp put_status_responses( + %{request: request, optional_responses: optional_responses}, + {_version, status, reason}, + responses + ) do + responses = [{:status, request.ref, status} | responses] + + if Enum.member?(optional_responses, :status_reason) do + [{:status_reason, request.ref, reason} | responses] + else + responses + end + end + defp store_header(%{content_length: nil} = request, "content-length", value) do with {:ok, content_length} <- Parse.content_length_header(value), do: {:ok, %{request | content_length: content_length}} diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index b627c386..a354a5a9 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -581,6 +581,39 @@ defmodule Mint.HTTP1Test do end end + describe "status reason" do + setup %{port: port} do + assert {:ok, conn} = + HTTP1.connect(:http, "localhost", port, optional_responses: [:status_reason]) + + [conn: conn] + end + + test "returns with 200 OK", %{conn: conn} do + assert {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) + + assert {:ok, _conn, [{:status, ^ref, 200}, {:status_reason, ^ref, "OK"}]} = + HTTP1.stream(conn, {:tcp, conn.socket, "HTTP/1.1 200 OK\r\n"}) + end + + test "returns with 404 Not Found", %{conn: conn} do + assert {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) + + assert {:ok, _conn, [{:status, ^ref, 404}, {:status_reason, ^ref, "Not Found"}]} = + HTTP1.stream(conn, {:tcp, conn.socket, "HTTP/1.1 404 Not Found\r\n"}) + end + + test "returns empty string when reason is not provided", %{conn: conn} do + assert {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) + + assert {:ok, _conn, [{:status, ^ref, 200}, {:status_reason, ^ref, ""}]} = + HTTP1.stream( + conn, + {:tcp, conn.socket, "HTTP/1.1 200\r\n"} + ) + end + end + describe "non-streaming requests" do test "content-length header is added if not present", %{conn: conn, server_socket: server_socket, port: port} do From e196c14d33fb6377ea752cc3504e8c19aede742d Mon Sep 17 00:00:00 2001 From: Pradeep Kumar Date: Sun, 19 Oct 2025 16:52:41 +0530 Subject: [PATCH 2/4] Added doc --- lib/mint/http.ex | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/mint/http.ex b/lib/mint/http.ex index e6a2abc4..5fceaf24 100644 --- a/lib/mint/http.ex +++ b/lib/mint/http.ex @@ -759,6 +759,12 @@ defmodule Mint.HTTP do You can have zero or more `1xx` `:status` and `:headers` responses for a single request, but they all precede a single non-`1xx` `:status` response. + * `{:status_reason, request_ref, reason_phrase}` - returned when the server replied + with a response status code and a reason-phrase. The reason-phrase is a string. + Returned when the `:optional_responses` option is passed to `connect/4`, with + `:status_reason` in the list. See `Mint.HTTP1.connect/4` for more information. + This is only available for HTTP/1.1 connections. *Available since v1.7.2*. + * `{:headers, request_ref, headers}` - returned when the server replied with a list of headers. Headers are in the form `{header_name, header_value}` with `header_name` and `header_value` being strings. A single `:headers` response From 31ed43b10044b133962ad90a018e67a22e5d18ae Mon Sep 17 00:00:00 2001 From: Pradeep Kumar Date: Mon, 20 Oct 2025 18:46:14 +0530 Subject: [PATCH 3/4] validate opts --- lib/mint/http1.ex | 28 +++++++++++++++++++++++----- test/mint/http1/conn_test.exs | 6 ++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index e337d5db..5b27de96 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -83,6 +83,8 @@ defmodule Mint.HTTP1 do """ @type error_reason() :: term() + @optional_responses_opts [:status_reason] + defstruct [ :host, :port, @@ -130,10 +132,11 @@ defmodule Mint.HTTP1 do conforming URIs but need to preserve them. The default is to validate the request target. *Available since v1.7.0*. * `:optional_responses` - (list of atoms) a list of optional responses to return. - The possible values in the list are `:status_reason` which will return the - [reason-phrase](https://datatracker.ietf.org/doc/html/rfc9112#name-status-line) - for the status code, if it is returned by the server in status-line. - This is only available for HTTP/1.1 connections. *Available since v1.7.2*. + The possible values in the list are - + * `:status_reason` which will return the + [reason-phrase](https://datatracker.ietf.org/doc/html/rfc9112#name-status-line) + for the status code, if it is returned by the server in status-line. + This is only available for HTTP/1.1 connections. *Available since v1.7.2*. """ @spec connect(Types.scheme(), Types.address(), :inet.port_number(), keyword()) :: @@ -213,7 +216,7 @@ defmodule Mint.HTTP1 do log: log?, case_sensitive_headers: Keyword.get(opts, :case_sensitive_headers, false), skip_target_validation: Keyword.get(opts, :skip_target_validation, false), - optional_responses: Keyword.get(opts, :optional_responses, []) + optional_responses: validate_optional_response_values(opts) } {:ok, conn} @@ -224,6 +227,21 @@ defmodule Mint.HTTP1 do end end + defp validate_optional_response_values(opts) do + opts + |> Keyword.get(:optional_responses, []) + |> Enum.map(fn opt -> + if opt not in @optional_responses_opts do + raise ArgumentError, """ + invalid :optional_responses value #{opt}. + allowed values are - #{inspect(@optional_responses_opts)} + """ + end + + opt + end) + end + @doc """ See `Mint.HTTP.close/1`. """ diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index a354a5a9..66f39876 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -187,6 +187,12 @@ defmodule Mint.HTTP1Test do refute HTTP1.open?(conn) end + test "raises error connecting with invalid optional_responses params", %{port: port} do + assert_raise ArgumentError, fn -> + HTTP1.connect(:http, "localhost", port, optional_responses: [:someting]) + end + end + test "pipeline", %{conn: conn} do {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) From 589a11aadabf3b16e7cad7c8295cb8381570e43d Mon Sep 17 00:00:00 2001 From: Pradeep Kumar Date: Tue, 21 Oct 2025 13:02:05 +0530 Subject: [PATCH 4/4] fix unrelated, failing test --- test/mint/http2/integration_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mint/http2/integration_test.exs b/test/mint/http2/integration_test.exs index 3a901c63..1cfccdfb 100644 --- a/test/mint/http2/integration_test.exs +++ b/test/mint/http2/integration_test.exs @@ -124,7 +124,7 @@ defmodule HTTP2.IntegrationTest do assert {:ok, %HTTP2{} = conn, responses} = receive_stream(conn) assert [{:status, ^ref, status}, {:headers, ^ref, headers} | rest] = responses - assert status in [200, 302] + assert status in [200, 301, 302] assert {_, [{:done, ^ref}]} = Enum.split_while(rest, &match?({:data, ^ref, _}, &1))