From cebd703de661bd786d99c039b906249667cd6f6d Mon Sep 17 00:00:00 2001 From: John Davenport Date: Tue, 11 Aug 2026 20:02:19 -0400 Subject: [PATCH 1/3] Record against Req 0.7, where the Finch adapter is a module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The record path built its passthrough with `adapter: &Req.Steps.run_finch/1`. Req 0.7 moved that to the `Req.Finch` adapter module, so the capture raises UndefinedFunctionError the moment a cassette is written. Replay never touches this path, so an application on Req 0.7 keeps replaying its existing cassettes and looks healthy. The breakage only surfaces when someone records a new one — which is where it is least welcome. Resolved at runtime so one build works across the rename, and the version range widened to admit 0.7. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01M6g6U83HzmVZmeCiyD1Ay4 --- lib/req_cassette/plug.ex | 28 +++++++++++++++++++++------- mix.exs | 2 +- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/lib/req_cassette/plug.ex b/lib/req_cassette/plug.ex index f033d56..abff23a 100644 --- a/lib/req_cassette/plug.ex +++ b/lib/req_cassette/plug.ex @@ -848,18 +848,32 @@ defmodule ReqCassette.Plug do # Create a new Req without the plug option to avoid infinite recursion. # - # Req.Steps.run_finch/1 is Req internals, not public API, so it is the - # single point the req version range in mix.exs rests on. A Req release - # that renames or reshapes it breaks recording here - # without any deprecation warning; the compatibility matrix in - # .github/workflows/ci.yml is what catches that, so widening the range - # means adding a row there rather than just editing the constraint. - req = Req.new([adapter: &Steps.run_finch/1] ++ forwarded_opts) + # The default adapter is Req internals, not public API, so it is the single + # point the req version range in mix.exs rests on. A Req release that + # renames or reshapes it breaks recording here without any deprecation + # warning; the compatibility matrix in .github/workflows/ci.yml is what + # catches that, so widening the range means adding a row there rather than + # just editing the constraint. + # + # It changed exactly that way in 0.7: `Req.Steps.run_finch/1` became the + # `Req.Finch` adapter module. Because the capture only raises when a + # recording is actually made, replay went on working and the breakage was + # invisible until someone recorded a new cassette. + req = Req.new([adapter: default_adapter()] ++ forwarded_opts) resp = Req.request(req, req_opts) {conn, resp} end + # Resolved at runtime rather than compile time so one build works against + # either Req line — the supported range spans the rename. + defp default_adapter do + case Code.ensure_loaded?(Req.Finch) do + true -> Req.Finch + false -> &Steps.run_finch/1 + end + end + defp resp_to_conn(conn, %{status: status, headers: headers, body: body}) do conn |> put_resp_headers(headers) diff --git a/mix.exs b/mix.exs index 6eca39c..8fe23f3 100644 --- a/mix.exs +++ b/mix.exs @@ -32,7 +32,7 @@ defmodule ReqCassette.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:req, "~> 0.5.15 or ~> 0.6"}, + {:req, "~> 0.5.15 or ~> 0.6 or ~> 0.7"}, {:plug, "~> 1.18"}, {:jason, "~> 1.4"}, {:req_llm, "~> 1.0.0-rc.7", only: [:dev, :test], runtime: false}, From 1c7bbdfe77212dcc1e3b07d69decbd4963bee306 Mon Sep 17 00:00:00 2001 From: John Davenport Date: Tue, 11 Aug 2026 20:03:25 -0400 Subject: [PATCH 2/3] Resolve the pre-0.7 adapter indirectly so 0.7 builds are warning-free Naming Steps.run_finch/1 in the fallback branch warns on every build against Req 0.7, which is the configuration the fallback exists to avoid. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01M6g6U83HzmVZmeCiyD1Ay4 --- lib/req_cassette/plug.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/req_cassette/plug.ex b/lib/req_cassette/plug.ex index abff23a..7feeead 100644 --- a/lib/req_cassette/plug.ex +++ b/lib/req_cassette/plug.ex @@ -870,7 +870,9 @@ defmodule ReqCassette.Plug do defp default_adapter do case Code.ensure_loaded?(Req.Finch) do true -> Req.Finch - false -> &Steps.run_finch/1 + # Req < 0.7. Captured indirectly: naming it directly warns on every + # build against 0.7, where the function this branch exists for is gone. + false -> Function.capture(Steps, :run_finch, 1) end end From 5768f7fdbf8442500a98dc582914fc414d8a2fdc Mon Sep 17 00:00:00 2001 From: John Davenport Date: Tue, 11 Aug 2026 20:56:53 -0400 Subject: [PATCH 3/3] Record every request when replay will be sequential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sequential replay is strictly positional: interaction N answers request N. But record mode answers a repeat from the file instead of performing it, so a request made three times is stored once — and the replay that follows runs out on the second, reporting no matching interaction for a request the cassette visibly contains. The diagnostic says as much: method matched, URI matched, body matched, nothing left. That combination is easy to reach and hard to read. Anything that polls until a resource is ready records one poll; a page that re-reads a listing in reaction to an event records one read. The recording looks complete and the replay is short by however many repeats happened. Under sequential the only coherent recording is one interaction per request actually made, so record every one. Non-sequential cassettes keep collapsing repeats, which is what you want there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01M6g6U83HzmVZmeCiyD1Ay4 --- lib/req_cassette/plug.ex | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/lib/req_cassette/plug.ex b/lib/req_cassette/plug.ex index 7feeead..de5329c 100644 --- a/lib/req_cassette/plug.ex +++ b/lib/req_cassette/plug.ex @@ -504,15 +504,29 @@ defmodule ReqCassette.Plug do filter_opts = extract_filter_opts(opts) filtered_request = build_and_filter_incoming_request(conn, body, filter_opts) - case find_matching_interaction_record(cassette, filtered_request, match_on, path, opts) do - {:ok, response} -> - send_cached_response(conn, response) - - :not_found -> + cond do + # Sequential replay is strictly positional: interaction N answers request + # N. Answering a repeat from the file while recording means the repeat is + # never appended, so a request made three times is stored once — and the + # replay that follows runs out on the second, reporting no matching + # interaction for a request the cassette visibly contains. + # + # Under `sequential` the only coherent recording is one interaction per + # request actually made, so record every one. Non-sequential cassettes + # keep the old behaviour, where collapsing repeats is what you want. + sequential?(opts) -> record_new_interaction(conn, body, opts, cassette, filtered_request, path) + + true -> + case find_matching_interaction_record(cassette, filtered_request, match_on, path, opts) do + {:ok, response} -> send_cached_response(conn, response) + :not_found -> record_new_interaction(conn, body, opts, cassette, filtered_request, path) + end end end + defp sequential?(opts), do: opts[:sequential] == true or opts[:session_id] != nil + defp handle_record_new_cassette(conn, body, opts, path) do filter_opts = extract_filter_opts(opts) filtered_request = build_and_filter_incoming_request(conn, body, filter_opts)