From 9b432685a00d46de61fe1ac913e6142172c43024 Mon Sep 17 00:00:00 2001 From: kenichirow <> Date: Wed, 18 Feb 2026 00:09:37 +0900 Subject: [PATCH] Add StealResponseInjector and refactor Injector to protocol Convert Injector from behaviour to protocol for cleaner dispatch, add action field to Response, and introduce StealResponseInjector that simulates stolen responses via Process.exit(:kill). Co-Authored-By: Claude Opus 4.6 --- lib/faultex.ex | 28 ++--------- lib/faultex/httpoison.ex | 29 +++++++---- lib/faultex/injector.ex | 7 +-- lib/faultex/injector/chain_injector.ex | 7 +-- lib/faultex/injector/error_injector.ex | 8 ++-- lib/faultex/injector/random_injector.ex | 7 +-- lib/faultex/injector/reject_injector.ex | 9 ++-- lib/faultex/injector/slow_injector.ex | 9 ++-- .../injector/steal_response_injector.ex | 36 ++++++++++++++ lib/faultex/matcher.ex | 18 ++++--- lib/faultex/plug.ex | 37 +++++++++----- lib/faultex/response.ex | 5 +- test/faultex/httpoison_test.exs | 39 +++++++++++++++ test/faultex/plug_test.exs | 48 +++++++++++++++++-- 14 files changed, 209 insertions(+), 78 deletions(-) create mode 100644 lib/faultex/injector/steal_response_injector.ex diff --git a/lib/faultex.ex b/lib/faultex.ex index 1a267e0..c679be7 100644 --- a/lib/faultex.ex +++ b/lib/faultex.ex @@ -10,30 +10,8 @@ defmodule Faultex do end end - @spec inject( - Faultex.Injector.ErrorInjector.t() - | Faultex.Injector.SlowInjector.t() - | Faultex.Injector.RejectInjector.t() - | Faultex.Injector.RandomInjector.t() - | Faultex.Injector.ChainInjector.t() - ) :: Faultex.Response.t() - def inject(%Faultex.Injector.ErrorInjector{} = injector) do - Faultex.Injector.ErrorInjector.inject(injector) - end - - def inject(%Faultex.Injector.SlowInjector{} = injector) do - Faultex.Injector.SlowInjector.inject(injector) - end - - def inject(%Faultex.Injector.RejectInjector{} = injector) do - Faultex.Injector.RejectInjector.inject(injector) - end - - def inject(%Faultex.Injector.RandomInjector{} = injector) do - Faultex.Injector.RandomInjector.inject(injector) - end - - def inject(%Faultex.Injector.ChainInjector{} = injector) do - Faultex.Injector.ChainInjector.inject(injector) + @spec inject(Faultex.Injector.t()) :: Faultex.Response.t() + def inject(injector) do + Faultex.Injector.inject(injector) end end diff --git a/lib/faultex/httpoison.ex b/lib/faultex/httpoison.ex index 78fd8dc..998919e 100644 --- a/lib/faultex/httpoison.ex +++ b/lib/faultex/httpoison.ex @@ -21,14 +21,27 @@ defmodule Faultex.HTTPoison do {true, injector} -> resp = Faultex.inject(injector) - {:ok, - %HTTPoison.Response{ - body: resp.body, - headers: resp.headers, - request: request, - request_url: url, - status_code: resp.status - }} + case resp.action do + :reject -> + {:error, %HTTPoison.Error{reason: :closed}} + + :passthrough -> + super(method, url, body, headers, options) + + :response -> + {:ok, + %HTTPoison.Response{ + body: resp.body, + headers: resp.headers, + request: request, + request_url: url, + status_code: resp.status + }} + + :steal -> + _ = super(method, url, body, headers, options) + {:error, %HTTPoison.Error{reason: :closed}} + end {false, _} -> super(method, url, body, headers, options) diff --git a/lib/faultex/injector.ex b/lib/faultex/injector.ex index 0747694..d57e0d8 100644 --- a/lib/faultex/injector.ex +++ b/lib/faultex/injector.ex @@ -1,7 +1,8 @@ -defmodule Faultex.Injector do +defprotocol Faultex.Injector do @moduledoc """ - Behaviour for fault injectors + Protocol for fault injectors """ - @callback inject(request :: term()) :: Faultex.Response.t() + @spec inject(t) :: Faultex.Response.t() + def inject(injector) end diff --git a/lib/faultex/injector/chain_injector.ex b/lib/faultex/injector/chain_injector.ex index c0f1be7..a03b203 100644 --- a/lib/faultex/injector/chain_injector.ex +++ b/lib/faultex/injector/chain_injector.ex @@ -25,9 +25,6 @@ defmodule Faultex.Injector.ChainInjector do :injectors ] - @behaviour Faultex.Injector - - @impl Faultex.Injector @spec inject(t()) :: Faultex.Response.t() def inject(%__MODULE__{injectors: injectors}) do Enum.reduce(injectors, %Faultex.Response{}, fn inj, _acc -> @@ -35,3 +32,7 @@ defmodule Faultex.Injector.ChainInjector do end) end end + +defimpl Faultex.Injector, for: Faultex.Injector.ChainInjector do + def inject(injector), do: Faultex.Injector.ChainInjector.inject(injector) +end diff --git a/lib/faultex/injector/error_injector.ex b/lib/faultex/injector/error_injector.ex index 6ce5bfd..5960d5f 100644 --- a/lib/faultex/injector/error_injector.ex +++ b/lib/faultex/injector/error_injector.ex @@ -33,9 +33,6 @@ defmodule Faultex.Injector.ErrorInjector do :resp_delay ] - @behaviour Faultex.Injector - - @impl Faultex.Injector @spec inject(t()) :: Faultex.Response.t() def inject(injector) do resp_delay = @@ -49,9 +46,14 @@ defmodule Faultex.Injector.ErrorInjector do end %Faultex.Response{ + action: :response, status: injector.resp_status, headers: injector.resp_headers, body: injector.resp_body } end end + +defimpl Faultex.Injector, for: Faultex.Injector.ErrorInjector do + def inject(injector), do: Faultex.Injector.ErrorInjector.inject(injector) +end diff --git a/lib/faultex/injector/random_injector.ex b/lib/faultex/injector/random_injector.ex index dd7d3e1..8885792 100644 --- a/lib/faultex/injector/random_injector.ex +++ b/lib/faultex/injector/random_injector.ex @@ -25,11 +25,12 @@ defmodule Faultex.Injector.RandomInjector do :injectors ] - @behaviour Faultex.Injector - - @impl Faultex.Injector @spec inject(t()) :: Faultex.Response.t() def inject(%__MODULE__{injectors: injectors}) do injectors |> Enum.random() |> Faultex.inject() end end + +defimpl Faultex.Injector, for: Faultex.Injector.RandomInjector do + def inject(injector), do: Faultex.Injector.RandomInjector.inject(injector) +end diff --git a/lib/faultex/injector/reject_injector.ex b/lib/faultex/injector/reject_injector.ex index 853dc97..eef8ecf 100644 --- a/lib/faultex/injector/reject_injector.ex +++ b/lib/faultex/injector/reject_injector.ex @@ -25,11 +25,12 @@ defmodule Faultex.Injector.RejectInjector do :resp_delay ] - @behaviour Faultex.Injector - - @impl Faultex.Injector @spec inject(t()) :: Faultex.Response.t() def inject(_injector) do - %Faultex.Response{headers: [], body: ""} + %Faultex.Response{action: :reject, headers: [], body: ""} end end + +defimpl Faultex.Injector, for: Faultex.Injector.RejectInjector do + def inject(injector), do: Faultex.Injector.RejectInjector.inject(injector) +end diff --git a/lib/faultex/injector/slow_injector.ex b/lib/faultex/injector/slow_injector.ex index c8a26cc..2f6a177 100644 --- a/lib/faultex/injector/slow_injector.ex +++ b/lib/faultex/injector/slow_injector.ex @@ -25,9 +25,6 @@ defmodule Faultex.Injector.SlowInjector do :resp_delay ] - @behaviour Faultex.Injector - - @impl Faultex.Injector @spec inject(t()) :: Faultex.Response.t() def inject(injector) do resp_delay = @@ -40,6 +37,10 @@ defmodule Faultex.Injector.SlowInjector do Process.sleep(resp_delay) end - %Faultex.Response{} + %Faultex.Response{action: :passthrough} end end + +defimpl Faultex.Injector, for: Faultex.Injector.SlowInjector do + def inject(injector), do: Faultex.Injector.SlowInjector.inject(injector) +end diff --git a/lib/faultex/injector/steal_response_injector.ex b/lib/faultex/injector/steal_response_injector.ex new file mode 100644 index 0000000..e6b38bc --- /dev/null +++ b/lib/faultex/injector/steal_response_injector.ex @@ -0,0 +1,36 @@ +defmodule Faultex.Injector.StealResponseInjector do + @moduledoc """ + Simulates a stolen response where the server processes the request but the response never reaches the client. + """ + + @type t :: %__MODULE__{ + id: term(), + disable: boolean() | nil, + host: String.t() | nil, + method: String.t() | nil, + path: String.t() | nil, + headers: [{String.t(), String.t()}] | nil, + percentage: integer() | nil, + resp_delay: integer() | nil + } + + defstruct [ + :id, + :disable, + :host, + :method, + :path, + :headers, + :percentage, + :resp_delay + ] + + @spec inject(t()) :: Faultex.Response.t() + def inject(_injector) do + %Faultex.Response{action: :steal} + end +end + +defimpl Faultex.Injector, for: Faultex.Injector.StealResponseInjector do + def inject(injector), do: Faultex.Injector.StealResponseInjector.inject(injector) +end diff --git a/lib/faultex/matcher.ex b/lib/faultex/matcher.ex index b40e3d5..f52f235 100644 --- a/lib/faultex/matcher.ex +++ b/lib/faultex/matcher.ex @@ -3,12 +3,7 @@ defmodule Faultex.Matcher do """ @type header :: {String.t(), String.t()} - @type injector :: - Faultex.Injector.ErrorInjector.t() - | Faultex.Injector.SlowInjector.t() - | Faultex.Injector.RejectInjector.t() - | Faultex.Injector.RandomInjector.t() - | Faultex.Injector.ChainInjector.t() + @type injector :: Faultex.Injector.t() @type match_result :: {boolean(), injector() | nil} @type t :: %__MODULE__{ @@ -150,6 +145,17 @@ defmodule Faultex.Matcher do } end + def do_build_matcher(injector) when is_struct(injector, Faultex.Injector.StealResponseInjector) do + validate_injector!(injector) + + { + fill_matcher_params(injector), + %Faultex.Injector.StealResponseInjector{ + resp_delay: Map.get(injector, :resp_delay, 0) + } + } + end + def do_build_matcher(injector) when is_struct(injector, Faultex.Injector.RandomInjector) do validate_injector!(injector) validate_injectors!(injector.injectors) diff --git a/lib/faultex/plug.ex b/lib/faultex/plug.ex index d8e7397..d7b0e5d 100644 --- a/lib/faultex/plug.ex +++ b/lib/faultex/plug.ex @@ -25,22 +25,33 @@ defmodule Faultex.Plug do matcher = opts[:matcher] case match(matcher, conn) do - {true, %Faultex.Injector.SlowInjector{} = slow_injector} -> - _ = Faultex.inject(slow_injector) - conn - {true, injector} -> - send_resp_and_halt(conn, injector) + resp = Faultex.inject(injector) + + case resp.action do + :passthrough -> + conn + + :reject -> + conn |> Plug.Conn.halt() + + :response -> + send_resp_and_halt(conn, resp) + + :steal -> + Plug.Conn.register_before_send(conn, fn conn -> + Process.exit(self(), :kill) + conn + end) + end {false, _} -> conn end end - @spec send_resp_and_halt(Plug.Conn.t(), Faultex.Matcher.injector()) :: Plug.Conn.t() - def send_resp_and_halt(conn, injector) do - resp = Faultex.inject(injector) - + @spec send_resp_and_halt(Plug.Conn.t(), Faultex.Response.t()) :: Plug.Conn.t() + defp send_resp_and_halt(conn, resp) do conn |> put_resp_headers(resp.headers) |> Plug.Conn.send_resp(resp.status, resp.body) @@ -48,17 +59,17 @@ defmodule Faultex.Plug do end @spec put_resp_headers(Plug.Conn.t(), [{String.t(), String.t()}] | nil) :: Plug.Conn.t() - def put_resp_headers(conn, nil), do: conn - def put_resp_headers(conn, []), do: conn + defp put_resp_headers(conn, nil), do: conn + defp put_resp_headers(conn, []), do: conn - def put_resp_headers(conn, headers) do + defp put_resp_headers(conn, headers) do Enum.reduce(headers, conn, fn {k, v}, c -> Plug.Conn.put_resp_header(c, k, v) end) end @spec match(module(), Plug.Conn.t()) :: Faultex.Matcher.match_result() - def match(matcher, %Plug.Conn{} = conn) do + defp match(matcher, %Plug.Conn{} = conn) do %{ host: host, method: method, diff --git a/lib/faultex/response.ex b/lib/faultex/response.ex index e350f81..eac4166 100644 --- a/lib/faultex/response.ex +++ b/lib/faultex/response.ex @@ -3,11 +3,14 @@ defmodule Faultex.Response do Response struct returned by injectors """ + @type action :: :response | :passthrough | :reject | :steal + @type t :: %__MODULE__{ + action: action(), status: integer() | nil, headers: [{String.t(), String.t()}] | nil, body: String.t() | nil } - defstruct [:status, :headers, :body] + defstruct [:action, :status, :headers, :body] end diff --git a/test/faultex/httpoison_test.exs b/test/faultex/httpoison_test.exs index 0c11258..7fbdd1e 100644 --- a/test/faultex/httpoison_test.exs +++ b/test/faultex/httpoison_test.exs @@ -18,6 +18,45 @@ defmodule Faultex.HTTPoisonTest do ] end + defmodule MyApp.RejectHTTPoison do + use Faultex.HTTPoison, + injectors: [ + %Faultex.Injector.RejectInjector{ + host: "reject.example.com", + path: "/api", + method: "GET", + percentage: 100 + } + ] + end + + defmodule MyApp.StealHTTPoison do + use Faultex.HTTPoison, + injectors: [ + %Faultex.Injector.StealResponseInjector{ + host: "github.com", + path: "/steal", + method: "GET", + percentage: 100 + } + ] + end + + test "StealResponseInjector sends request then returns error with reason :closed" do + {:error, %HTTPoison.Error{reason: :closed}} = + MyApp.StealHTTPoison.get("https://github.com/steal") + end + + test "StealResponseInjector passes through when not matched" do + {:ok, res} = MyApp.StealHTTPoison.get("https://github.com/") + assert res.status_code == 200 + end + + test "RejectInjector returns error with reason :closed" do + {:error, %HTTPoison.Error{reason: :closed}} = + MyApp.RejectHTTPoison.get("https://reject.example.com/api") + end + test "Request to remote server" do {:ok, res} = MyApp.HTTPoison.get("https://github.com/", []) assert res.status_code == 200 diff --git a/test/faultex/plug_test.exs b/test/faultex/plug_test.exs index 4fd54e7..4846a05 100644 --- a/test/faultex/plug_test.exs +++ b/test/faultex/plug_test.exs @@ -223,13 +223,51 @@ defmodule Faultex.PlugTest do end end + defmodule StealRouter do + use Faultex.Plug, + injectors: [ + %Faultex.Injector.StealResponseInjector{path: "/steal", method: "GET", percentage: 100} + ] + + plug(:match) + plug(:dispatch) + + get "/steal" do + send_resp(conn, 200, "ok") + end + + post "/steal" do + send_resp(conn, 200, "ok") + end + end + + describe "StealResponseInjector" do + test "kills process before response is sent when matched" do + conn = Plug.Test.conn("GET", "/steal") + opts = StealRouter.init(matcher: StealRouter) + + pid = + spawn(fn -> + StealRouter.call(conn, opts) + end) + + ref = Process.monitor(pid) + assert_receive {:DOWN, ^ref, :process, ^pid, :killed}, 1000 + end + + test "returns normal response when not matched" do + conn = Plug.Test.conn("POST", "/steal") + conn = StealRouter.call(conn, StealRouter.init(matcher: StealRouter)) + assert conn.status == 200 + end + end + describe "RejectInjector" do - test "raises FunctionClauseError due to nil status when matched" do + test "halts without sending response when matched" do conn = Plug.Test.conn("GET", "/reject") - - assert_raise FunctionClauseError, fn -> - RejectRouter.call(conn, RejectRouter.init(matcher: RejectRouter)) - end + conn = RejectRouter.call(conn, RejectRouter.init(matcher: RejectRouter)) + assert conn.halted + assert conn.state == :unset end test "returns normal response when not matched" do