diff --git a/lib/req/checksum_mismatch_error.ex b/lib/req/checksum_mismatch_error.ex index 178950c7..984c1ec8 100644 --- a/lib/req/checksum_mismatch_error.ex +++ b/lib/req/checksum_mismatch_error.ex @@ -1,6 +1,11 @@ defmodule Req.ChecksumMismatchError do + @moduledoc """ + Represents a checksum mismatch error returned by `Req.Steps.checksum/1`. + """ + defexception [:expected, :actual] + @impl true def message(%{expected: expected, actual: actual}) do """ checksum mismatch diff --git a/lib/req/steps.ex b/lib/req/steps.ex index e0c890c5..63f81901 100644 --- a/lib/req/steps.ex +++ b/lib/req/steps.ex @@ -666,6 +666,9 @@ defmodule Req.Steps do ["Adapter" section in the `Req.Request`](Req.Request.html#module-adapter) module documentation for more information on adapters. + Finch returns `Mint.TransportError` exceptions on HTTP connection problems. These are automatically + converted to `Req.TransportError` exceptions. + ## HTTP/1 Pools On HTTP/1 connections, Finch creates a pool per `{scheme, host, port}` tuple. These pools @@ -757,6 +760,11 @@ defmodule Req.Steps do iex> Req.get!(url, connect_options: [transport_opts: [cacerts: :public_key.cacerts_get()]]) + Transport errors are represented as `Req.TransportError` exceptions: + + iex> Req.get("https://httpbin.org/delay/1", receive_timeout: 0, retry: false) + {:error, %Req.TransportError{reason: :timeout}} + Stream response body using `Finch.stream/5`: fun = fn request, finch_request, finch_name, finch_options -> @@ -876,6 +884,9 @@ defmodule Req.Steps do {:ok, acc} -> acc + {:error, %Mint.TransportError{reason: reason}} -> + {req, %Req.TransportError{reason: reason}} + {:error, exception} -> {req, exception} end @@ -909,6 +920,9 @@ defmodule Req.Steps do acc = collector.(acc, :done) {req, %{resp | body: acc}} + {:error, %Mint.TransportError{reason: reason}} -> + {req, %Req.TransportError{reason: reason}} + {:error, exception} -> {req, exception} end @@ -977,8 +991,14 @@ defmodule Req.Steps do defp run_finch_request(finch_request, finch_name, finch_options) do case Finch.request(finch_request, finch_name, finch_options) do - {:ok, response} -> Req.Response.new(response) - {:error, exception} -> exception + {:ok, response} -> + Req.Response.new(response) + + {:error, %Mint.TransportError{reason: reason}} -> + %Req.TransportError{reason: reason} + + {:error, exception} -> + exception end end @@ -1140,7 +1160,7 @@ defmodule Req.Steps do assert Req.get!("http:///hello", plug: echo).body == "hello" end - which is particularly useful to create HTTP service mocks, similar to tools like + which is particularly useful to create HTTP service stubs, similar to tools like [Bypass](https://github.com/PSPDFKit-labs/bypass). Response streaming is also supported however at the moment the entire response @@ -1156,6 +1176,31 @@ defmodule Req.Steps do assert Req.get!(plug: plug, into: []).body == ["echoecho"] end + + When testing JSON APIs, it's common to use the `Req.Test.json/2` helper: + + test "JSON" do + plug = fn conn -> + Req.Test.json(conn, %{message: "Hello, World!"}) + end + + resp = Req.get!(plug: plug) + assert resp.status == 200 + assert resp.headers["content-type"] == ["application/json; charset=utf-8"] + assert resp.body == %{"message" => "Hello, World!"} + end + + You can simulate network errors by calling `Req.Test.transport_error/2` + in your plugs: + + test "network issues" do + plug = fn conn -> + Req.Test.transport_error(conn, :timeout) + end + + assert Req.get(plug: plug, retry: false) == + {:error, %Req.TransportError{reason: :timeout}} + end """ @doc step: :request def put_plug(request) do @@ -1192,11 +1237,19 @@ defmodule Req.Steps do end end - conn = Plug.Test.conn(request.method, request.url, req_body) + conn = + Plug.Test.conn(request.method, request.url, req_body) + |> Map.replace!(:req_headers, req_headers) + |> call_plug(plug) - conn = put_in(conn.req_headers, req_headers) - conn = call_plug(conn, plug) + if exception = conn.private[:req_test_exception] do + {request, exception} + else + handle_plug_result(conn, request) + end + end + defp handle_plug_result(conn, request) do # consume messages sent by Plug.Test adapter {_, %{ref: ref}} = conn.adapter diff --git a/lib/req/test.ex b/lib/req/test.ex index 0d710836..3f5d67a6 100644 --- a/lib/req/test.ex +++ b/lib/req/test.ex @@ -206,6 +206,46 @@ defmodule Req.Test do end end + @doc """ + """ + def transport_error(conn, reason) + + if Code.ensure_loaded?(Plug.Conn) do + @spec transport_error(Plug.Conn.t(), reason :: atom()) :: Plug.Conn.t() + def transport_error(%Plug.Conn{} = conn, reason) do + validate_transport_error!(reason) + exception = Req.TransportError.exception(reason: reason) + put_in(conn.private[:req_test_exception], exception) + end + + defp validate_transport_error!(:protocol_not_negotiated), do: :ok + defp validate_transport_error!({:bad_alpn_protocol, _}), do: :ok + defp validate_transport_error!(:closed), do: :ok + defp validate_transport_error!(:timeout), do: :ok + + defp validate_transport_error!(reason) do + case :ssl.format_error(reason) do + ~c"Unexpected error:" ++ _ -> + raise ArgumentError, "unexpected Req.TransportError reason: #{inspect(reason)}" + + _ -> + :ok + end + end + else + def transport_error(_conn, _reason) do + Logger.error(""" + Could not find plug dependency. + + Please add :plug to your dependencies: + + {:plug, "~> 1.0"} + """) + + raise "missing plug dependency" + end + end + @doc """ Returns the stub created by `stub/2`. """ diff --git a/lib/req/too_many_redirects_error.ex b/lib/req/too_many_redirects_error.ex index 1a972ec9..e92f218b 100644 --- a/lib/req/too_many_redirects_error.ex +++ b/lib/req/too_many_redirects_error.ex @@ -1,6 +1,11 @@ defmodule Req.TooManyRedirectsError do + @moduledoc """ + Represents an error when too many redirects occured, returned by `Req.Steps.redirect/1`. + """ + defexception [:max_redirects] + @impl true def message(%{max_redirects: max_redirects}) do "too many redirects (#{max_redirects})" end diff --git a/lib/req/transport_error.ex b/lib/req/transport_error.ex new file mode 100644 index 00000000..689d1573 --- /dev/null +++ b/lib/req/transport_error.ex @@ -0,0 +1,17 @@ +defmodule Req.TransportError do + @moduledoc """ + Represents an error with the transport used by an HTTP connection. + + This is a standardised exception that all Req adapters should use for transport-layer-related + errors. + + This exception is based on `Mint.TransportError`. + """ + + defexception [:reason] + + @impl true + def message(%__MODULE__{reason: reason}) do + Mint.TransportError.message(%Mint.TransportError{reason: reason}) + end +end diff --git a/test/req/steps_test.exs b/test/req/steps_test.exs index 06451653..596883ea 100644 --- a/test/req/steps_test.exs +++ b/test/req/steps_test.exs @@ -1777,6 +1777,19 @@ defmodule Req.StepsTest do assert ["defmodule Req.MixProject do" <> _] = resp.body refute_receive _ end + + test "errors" do + req = + Req.new( + plug: fn conn -> + Req.Test.transport_error(conn, :timeout) + end, + retry: false + ) + + assert Req.request(req) == + {:error, %Req.TransportError{reason: :timeout}} + end end describe "run_finch" do @@ -1849,7 +1862,7 @@ defmodule Req.StepsTest do end) req = Req.new(url: url, receive_timeout: 50, retry: false) - assert {:error, %Mint.TransportError{reason: :timeout}} = Req.request(req) + assert {:error, %Req.TransportError{reason: :timeout}} = Req.request(req) assert_received :ping end @@ -2044,7 +2057,7 @@ defmodule Req.StepsTest do test "into: fun handle error", %{bypass: bypass, url: url} do Bypass.down(bypass) - assert {:error, %Mint.TransportError{reason: :econnrefused}} = + assert {:error, %Req.TransportError{reason: :econnrefused}} = Req.get( url: url, retry: false, @@ -2097,7 +2110,7 @@ defmodule Req.StepsTest do test "into: collectable handle error", %{bypass: bypass, url: url} do Bypass.down(bypass) - assert {:error, %Mint.TransportError{reason: :econnrefused}} = + assert {:error, %Req.TransportError{reason: :econnrefused}} = Req.get( url: url, retry: false, diff --git a/test/req/test_test.exs b/test/req/test_test.exs index ddf9a14f..520e2026 100644 --- a/test/req/test_test.exs +++ b/test/req/test_test.exs @@ -67,4 +67,12 @@ defmodule Req.TestTest do assert_receive {^ref, 1} end end + + describe "transport_error/2" do + test "validate reason" do + assert_raise ArgumentError, "unexpected Req.TransportError reason: :bad", fn -> + Req.Test.transport_error(%Plug.Conn{}, :bad) + end + end + end end