From 09189a99ac42504ed15373d6fc9eb70704484ef2 Mon Sep 17 00:00:00 2001 From: Jakub Jasiulewicz Date: Tue, 12 May 2026 16:38:27 +0200 Subject: [PATCH 1/6] Allow users to provide a custom HTTP client Introduce a `ConfigCat.HTTPClient` behaviour with a normalized request contract (plain maps for response/error, no HTTPoison structs at the boundary). `ConfigCat.API` becomes the default adapter wrapping HTTPoison and classifying transient vs. permanent errors. Callers can swap in Finch, Req, Mint, Tesla or a test stub via the new `:http_client` start option, which the supervisor plumbs through to the config fetcher. --- lib/config_cat.ex | 12 +++++ lib/config_cat/api.ex | 19 +++++-- lib/config_cat/config_fetcher.ex | 28 ++++++----- lib/config_cat/http_client.ex | 43 ++++++++++++++++ lib/config_cat/supervisor.ex | 1 + test/config_cat/config_fetcher_test.exs | 64 ++++++++++-------------- test/config_cat/data_governance_test.exs | 55 ++++++++++---------- test/support/cache_policy_case.ex | 3 +- test/support/mocks.ex | 2 +- 9 files changed, 142 insertions(+), 85 deletions(-) create mode 100644 lib/config_cat/http_client.ex diff --git a/lib/config_cat.ex b/lib/config_cat.ex index 5d20177b..959fdc20 100644 --- a/lib/config_cat.ex +++ b/lib/config_cat.ex @@ -95,6 +95,17 @@ defmodule ConfigCat do - `hooks`: **OPTIONAL** Specify callback functions to be called when particular events are fired by the SDK. See `ConfigCat.Hooks`. + - `http_client`: **OPTIONAL** Module implementing the `ConfigCat.HTTPClient` + behaviour, used to perform HTTP requests against the ConfigCat CDN. Defaults + to `ConfigCat.API`, which is built on + [HTTPoison](https://hex.pm/packages/httpoison). Provide your own adapter to + route requests through Finch, Req, Mint, Tesla, or any other client (or to + stub HTTP in tests). + + ```elixir + {ConfigCat, [sdk_key: "YOUR SDK KEY", http_client: MyApp.ConfigCatClient]} + ``` + - `http_proxy`: **OPTIONAL** Specify this option if you need to use a proxy server to access your ConfigCat settings. You can provide a simple URL, like `https://my_proxy.example.com` or include authentication information, like @@ -261,6 +272,7 @@ defmodule ConfigCat do | {:default_user, User.t()} | {:flag_overrides, OverrideDataSource.t()} | {:hooks, [Hooks.option()]} + | {:http_client, module()} | {:http_proxy, String.t()} | {:name, instance_id()} | {:offline, boolean()} diff --git a/lib/config_cat/api.ex b/lib/config_cat/api.ex index 452ab857..c62eb0f2 100644 --- a/lib/config_cat/api.ex +++ b/lib/config_cat/api.ex @@ -1,10 +1,21 @@ defmodule ConfigCat.API do @moduledoc false - use HTTPoison.Base + @behaviour ConfigCat.HTTPClient - @impl HTTPoison.Base - def process_request_headers(headers) do - [{"Accept", "application/json"} | headers] + @timeout_reasons ~w(checkout_timeout timeout connect_timeout)a + @transient_reasons @timeout_reasons ++ ~w(closed econnrefused nxdomain)a + + @impl ConfigCat.HTTPClient + def get(url, headers, opts) do + headers = [{"Accept", "application/json"} | headers] + + case HTTPoison.get(url, headers, opts) do + {:ok, %HTTPoison.Response{status_code: status, body: body, headers: response_headers}} -> + {:ok, %{status: status, body: body, headers: response_headers}} + + {:error, %HTTPoison.Error{reason: reason}} -> + {:error, %{reason: reason, transient?: reason in @transient_reasons}} + end end end diff --git a/lib/config_cat/config_fetcher.ex b/lib/config_cat/config_fetcher.ex index c47bd6ce..1da1d1f5 100644 --- a/lib/config_cat/config_fetcher.ex +++ b/lib/config_cat/config_fetcher.ex @@ -2,7 +2,6 @@ defmodule ConfigCat.ConfigFetcher do @moduledoc false alias ConfigCat.ConfigEntry - alias HTTPoison.Response defmodule FetchError do @moduledoc false @@ -43,7 +42,6 @@ defmodule ConfigCat.CacheControlConfigFetcher do alias ConfigCat.ConfigEntry alias ConfigCat.ConfigFetcher alias ConfigCat.ConfigFetcher.FetchError - alias HTTPoison.Response require ConfigCat.ConfigCatLogger, as: ConfigCatLogger require ConfigCat.Constants, as: Constants @@ -54,7 +52,7 @@ defmodule ConfigCat.CacheControlConfigFetcher do use TypedStruct typedstruct enforce: true do - field :api, module(), default: ConfigCat.API + field :http_client, module(), default: ConfigCat.API field :base_url, String.t() field :callers, [GenServer.from()], default: [] field :connect_timeout_milliseconds, non_neg_integer(), default: 8_000 @@ -104,6 +102,7 @@ defmodule ConfigCat.CacheControlConfigFetcher do {:base_url, String.t()} | {:connect_timeout_milliseconds, non_neg_integer()} | {:data_governance, ConfigCat.data_governance()} + | {:http_client, module()} | {:http_proxy, String.t()} | {:instance_id, ConfigCat.instance_id()} | {:mode, String.t()} @@ -174,11 +173,11 @@ defmodule ConfigCat.CacheControlConfigFetcher do defp do_fetch(%State{} = state, etag) do ConfigCatLogger.debug("Fetching configuration from ConfigCat") - case state.api.get(url(state), headers(state, etag), http_options(state)) do + case state.http_client.get(url(state), headers(state, etag), http_options(state)) do {:ok, response} -> handle_response(response, state, etag) - error -> + {:error, error} -> {:error, handle_error(error, state), state} end end @@ -222,7 +221,7 @@ defmodule ConfigCat.CacheControlConfigFetcher do # This function is slightly complex, but still reasonably understandable. # Breaking it up doesn't seem like it will help much. # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity - defp handle_response(%Response{status_code: code, body: raw_config, headers: headers}, %State{} = state, etag) + defp handle_response(%{status: code, body: raw_config, headers: headers}, %State{} = state, etag) when code >= 200 and code < 300 do ConfigCatLogger.debug("ConfigCat configuration json fetch response code: #{code} Cached: #{extract_etag(headers)}") @@ -282,11 +281,11 @@ defmodule ConfigCat.CacheControlConfigFetcher do end end - defp handle_response(%Response{status_code: 304}, %State{} = state, _etag) do + defp handle_response(%{status: 304}, %State{} = state, _etag) do {:ok, :unchanged, state} end - defp handle_response(%Response{status_code: status} = response, %State{} = state, _etag) when status in [403, 404] do + defp handle_response(%{status: status} = response, %State{} = state, _etag) when status in [403, 404] do ConfigCatLogger.error( "Your SDK Key seems to be wrong. You can find the valid SDKKey at https://app.configcat.com/sdkkey. Received unexpected response: #{inspect(response)}", event_id: 1100 @@ -308,25 +307,28 @@ defmodule ConfigCat.CacheControlConfigFetcher do {:error, error, state} end - defp handle_error({:error, %HTTPoison.Error{reason: :checkout_timeout} = error}, %State{} = state) do + @timeout_reasons ~w(checkout_timeout timeout connect_timeout)a + + defp handle_error(%{reason: reason, transient?: transient?}, %State{} = state) + when reason in @timeout_reasons do ConfigCatLogger.error( "Request timed out while trying to fetch config JSON. Timeout values: [connect: #{state.connect_timeout_milliseconds}ms, read: #{state.read_timeout_milliseconds}ms]", event_id: 1102 ) - FetchError.exception(reason: error, transient?: true) + FetchError.exception(reason: reason, transient?: transient?) end - defp handle_error({:error, error}, _state) do + defp handle_error(%{reason: reason, transient?: transient?}, _state) do ConfigCatLogger.error( "Unexpected error occurred while trying to fetch config JSON. " <> "It is most likely due to a local network issue. " <> "Please make sure your application can reach the ConfigCat CDN servers (or your proxy server) over HTTP. " <> - "#{inspect(error)}", + "#{inspect(reason)}", event_id: 1103 ) - FetchError.exception(reason: error, transient?: true) + FetchError.exception(reason: reason, transient?: transient?) end defp extract_etag(headers) do diff --git a/lib/config_cat/http_client.ex b/lib/config_cat/http_client.ex new file mode 100644 index 00000000..061eb445 --- /dev/null +++ b/lib/config_cat/http_client.ex @@ -0,0 +1,43 @@ +defmodule ConfigCat.HTTPClient do + @moduledoc """ + Behaviour for the HTTP transport used by the ConfigCat SDK. + + The SDK ships with `ConfigCat.API`, a default adapter built on + [HTTPoison](https://hex.pm/packages/httpoison). To plug in a different HTTP + client (Finch, Req, Mint, Tesla, a test stub, ...) implement this behaviour + and pass the module via the `:http_client` option to `ConfigCat.start_link/1`. + + defmodule MyApp.ConfigCatClient do + @behaviour ConfigCat.HTTPClient + + @impl true + def get(url, headers, opts) do + # Translate to your favourite HTTP client and return a normalized + # `{:ok, %{status: _, body: _, headers: _}}` or + # `{:error, %{reason: _, transient?: _}}`. + end + end + + ConfigCat.start_link(sdk_key: "...", http_client: MyApp.ConfigCatClient) + + Adapters are responsible for classifying errors as transient (the SDK will + treat them as retryable) or permanent. + """ + + @type header :: {String.t(), String.t()} + @type url :: String.t() + @type opts :: keyword() + + @type response :: %{ + required(:status) => 100..599, + required(:body) => binary(), + required(:headers) => [header()] + } + + @type error :: %{ + required(:reason) => any(), + required(:transient?) => boolean() + } + + @callback get(url(), [header()], opts()) :: {:ok, response()} | {:error, error()} +end diff --git a/lib/config_cat/supervisor.ex b/lib/config_cat/supervisor.ex index 097287bf..6946c8dc 100644 --- a/lib/config_cat/supervisor.ex +++ b/lib/config_cat/supervisor.ex @@ -135,6 +135,7 @@ defmodule ConfigCat.Supervisor do |> Keyword.put(:mode, options[:cache_policy].mode) |> Keyword.take([ :base_url, + :http_client, :http_proxy, :connect_timeout_milliseconds, :read_timeout_milliseconds, diff --git a/test/config_cat/config_fetcher_test.exs b/test/config_cat/config_fetcher_test.exs index f33ba03b..113890a4 100644 --- a/test/config_cat/config_fetcher_test.exs +++ b/test/config_cat/config_fetcher_test.exs @@ -9,7 +9,6 @@ defmodule ConfigCat.ConfigFetcherTest do alias ConfigCat.FetchTime alias ConfigCat.Hooks alias ConfigCat.MockAPI - alias HTTPoison.Response require ConfigCat.Constants, as: Constants @@ -27,7 +26,7 @@ defmodule ConfigCat.ConfigFetcherTest do start_supervised!({Hooks, instance_id: instance_id}) - default_options = [api: MockAPI, mode: mode, instance_id: instance_id, sdk_key: sdk_key] + default_options = [http_client: MockAPI, mode: mode, instance_id: instance_id, sdk_key: sdk_key] {:ok, pid} = start_supervised({ConfigFetcher, Keyword.merge(default_options, options)}) @@ -36,13 +35,17 @@ defmodule ConfigCat.ConfigFetcherTest do {:ok, instance_id} end + defp response(fields) do + Map.merge(%{status: 200, body: "", headers: []}, Map.new(fields)) + end + test "successful fetch" do {:ok, fetcher} = start_fetcher(@fetcher_options) url = global_config_url() stub(MockAPI, :get, fn ^url, _headers, _options -> - {:ok, %Response{status_code: 200, body: @raw_config, headers: [{"ETag", @etag}]}} + {:ok, response(status: 200, body: @raw_config, headers: [{"ETag", @etag}])} end) before = FetchTime.now_ms() @@ -65,7 +68,7 @@ defmodule ConfigCat.ConfigFetcherTest do expect(MockAPI, :get, 1, fn ^url, _headers, _options -> Process.sleep(50) - {:ok, %Response{status_code: 200, body: @raw_config, headers: [{"ETag", @etag}]}} + {:ok, response(status: 200, body: @raw_config, headers: [{"ETag", @etag}])} end) results = @@ -86,12 +89,12 @@ defmodule ConfigCat.ConfigFetcherTest do test "user agent header that includes the fetch mode" do {:ok, fetcher} = start_fetcher(@fetcher_options) - response = %Response{status_code: 200, body: @raw_config} + resp = response(status: 200, body: @raw_config) stub(MockAPI, :get, fn _url, headers, _options -> assert_user_agent_matches(headers, ~r"^ConfigCat-Elixir/#{@mode}-") - {:ok, response} + {:ok, resp} end) assert {:ok, _} = ConfigFetcher.fetch(fetcher, nil) @@ -100,11 +103,7 @@ defmodule ConfigCat.ConfigFetcherTest do test "sends proper cache control header on later requests" do {:ok, fetcher} = start_fetcher(@fetcher_options) - initial_response = %Response{ - status_code: 200, - body: @raw_config, - headers: [{"ETag", @etag}] - } + initial_response = response(status: 200, body: @raw_config, headers: [{"ETag", @etag}]) stub(MockAPI, :get, fn _url, headers, _options -> assert List.keyfind(headers, "ETag", 0) == nil @@ -113,10 +112,7 @@ defmodule ConfigCat.ConfigFetcherTest do {:ok, _} = ConfigFetcher.fetch(fetcher, nil) - not_modified_response = %Response{ - status_code: 304, - headers: [{"ETag", @etag}] - } + not_modified_response = response(status: 304, headers: [{"ETag", @etag}]) expect(MockAPI, :get, fn _url, headers, _options -> assert {"If-None-Match", @etag} = List.keyfind(headers, "If-None-Match", 0) @@ -129,24 +125,18 @@ defmodule ConfigCat.ConfigFetcherTest do test "returns unchanged response when server responds that the config hasn't changed" do {:ok, fetcher} = start_fetcher(@fetcher_options) - response = %Response{ - status_code: 304, - headers: [{"ETag", @etag}] - } + resp = response(status: 304, headers: [{"ETag", @etag}]) - stub(MockAPI, :get, fn _url, _headers, _options -> {:ok, response} end) + stub(MockAPI, :get, fn _url, _headers, _options -> {:ok, resp} end) assert {:ok, :unchanged} = ConfigFetcher.fetch(fetcher, @etag) end test "returns unchanged response (with lowercase 'etag') when server responds that the config hasn't changed" do {:ok, fetcher} = start_fetcher(@fetcher_options) - response = %Response{ - status_code: 304, - headers: [{"etag", @etag}] - } + resp = response(status: 304, headers: [{"etag", @etag}]) - stub(MockAPI, :get, fn _url, _headers, _options -> {:ok, response} end) + stub(MockAPI, :get, fn _url, _headers, _options -> {:ok, resp} end) assert {:ok, :unchanged} = ConfigFetcher.fetch(fetcher, @etag) end @@ -154,20 +144,20 @@ defmodule ConfigCat.ConfigFetcherTest do test "returns error for non-200 response from ConfigCat" do {:ok, fetcher} = start_fetcher(@fetcher_options) - response = %Response{status_code: 503} + resp = response(status: 503) - stub(MockAPI, :get, fn _url, _headers, _options -> {:ok, response} end) - assert {:error, %FetchError{reason: ^response}} = ConfigFetcher.fetch(fetcher, nil) + stub(MockAPI, :get, fn _url, _headers, _options -> {:ok, resp} end) + assert {:error, %FetchError{reason: ^resp}} = ConfigFetcher.fetch(fetcher, nil) end @tag capture_log: true test "returns error for error response from ConfigCat" do {:ok, fetcher} = start_fetcher(@fetcher_options) - error = %HTTPoison.Error{reason: "failed"} + error = %{reason: :failed, transient?: true} stub(MockAPI, :get, fn _url, _headers, _options -> {:error, error} end) - assert {:error, %FetchError{reason: ^error}} = ConfigFetcher.fetch(fetcher, nil) + assert {:error, %FetchError{reason: :failed, transient?: true}} = ConfigFetcher.fetch(fetcher, nil) end test "allows base URL to be configured" do @@ -177,19 +167,19 @@ defmodule ConfigCat.ConfigFetcherTest do url = config_url(base_url, @sdk_key) - expect(MockAPI, :get, fn ^url, _headers, _options -> {:ok, %Response{status_code: 200, body: @raw_config}} end) + expect(MockAPI, :get, fn ^url, _headers, _options -> {:ok, response(status: 200, body: @raw_config)} end) {:ok, _} = ConfigFetcher.fetch(fetcher, nil) end test "uses default timeouts if none provided" do {:ok, fetcher} = start_fetcher(@fetcher_options) - response = %Response{status_code: 200, body: @raw_config} + resp = response(status: 200, body: @raw_config) expect(MockAPI, :get, fn _url, _headers, options -> assert Keyword.get(options, :recv_timeout) == 5000 assert Keyword.get(options, :timeout) == 8000 - {:ok, response} + {:ok, resp} end) {:ok, _} = ConfigFetcher.fetch(fetcher, nil) @@ -205,12 +195,12 @@ defmodule ConfigCat.ConfigFetcherTest do read_timeout_milliseconds: read_timeout ) - response = %Response{status_code: 200, body: @raw_config} + resp = response(status: 200, body: @raw_config) expect(MockAPI, :get, fn _url, _headers, options -> assert Keyword.get(options, :recv_timeout) == read_timeout assert Keyword.get(options, :timeout) == connect_timeout - {:ok, response} + {:ok, resp} end) {:ok, _} = ConfigFetcher.fetch(fetcher, nil) @@ -220,11 +210,11 @@ defmodule ConfigCat.ConfigFetcherTest do proxy = "https://PROXY" {:ok, fetcher} = start_fetcher(@fetcher_options, http_proxy: proxy) - response = %Response{status_code: 200, body: @raw_config} + resp = response(status: 200, body: @raw_config) expect(MockAPI, :get, fn _url, _headers, options -> assert Keyword.get(options, :proxy) == proxy - {:ok, response} + {:ok, resp} end) {:ok, _} = ConfigFetcher.fetch(fetcher, nil) diff --git a/test/config_cat/data_governance_test.exs b/test/config_cat/data_governance_test.exs index 8e7372d9..75707abf 100644 --- a/test/config_cat/data_governance_test.exs +++ b/test/config_cat/data_governance_test.exs @@ -9,7 +9,6 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do alias ConfigCat.ConfigEntry alias ConfigCat.Hooks alias ConfigCat.MockAPI - alias HTTPoison.Response require ConfigCat.Constants, as: Constants require ConfigCat.RedirectMode, as: RedirectMode @@ -30,7 +29,7 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do start_supervised!({Hooks, instance_id: instance_id}) - default_options = [api: MockAPI, instance_id: instance_id, mode: mode, sdk_key: sdk_key] + default_options = [http_client: MockAPI, instance_id: instance_id, mode: mode, sdk_key: sdk_key] {:ok, pid} = start_supervised({ConfigFetcher, Keyword.merge(default_options, options)}) allow(MockAPI, self(), pid) @@ -50,13 +49,13 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do MockAPI |> expect(:get, 2, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: raw_config}} + {:ok, %{status: 200, headers: [], body: raw_config}} end) |> expect(:get, 0, fn ^eu_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: raw_config}} + {:ok, %{status: 200, headers: [], body: raw_config}} end) |> expect(:get, 0, fn ^redirect_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: raw_config}} + {:ok, %{status: 200, headers: [], body: raw_config}} end) assert {:ok, %ConfigEntry{config: ^config}} = ConfigFetcher.fetch(fetcher, nil) @@ -75,13 +74,13 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do MockAPI |> expect(:get, 0, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: raw_config}} + {:ok, %{status: 200, headers: [], body: raw_config}} end) |> expect(:get, 2, fn ^eu_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: raw_config}} + {:ok, %{status: 200, headers: [], body: raw_config}} end) |> expect(:get, 0, fn ^redirect_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: raw_config}} + {:ok, %{status: 200, headers: [], body: raw_config}} end) assert {:ok, %ConfigEntry{config: ^config}} = ConfigFetcher.fetch(fetcher, nil) @@ -99,10 +98,10 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do MockAPI |> expect(:get, 1, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_eu}} + {:ok, %{status: 200, headers: [], body: config_to_eu}} end) |> expect(:get, 2, fn ^eu_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_eu}} + {:ok, %{status: 200, headers: [], body: config_eu}} end) assert {:ok, _} = ConfigFetcher.fetch(fetcher, nil) @@ -120,10 +119,10 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do MockAPI |> expect(:get, 0, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_eu}} + {:ok, %{status: 200, headers: [], body: config_to_eu}} end) |> expect(:get, 2, fn ^eu_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_eu}} + {:ok, %{status: 200, headers: [], body: config_eu}} end) assert {:ok, _} = ConfigFetcher.fetch(fetcher, nil) @@ -145,13 +144,13 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do MockAPI |> expect(:get, 2, fn ^custom_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_global}} + {:ok, %{status: 200, headers: [], body: config_to_global}} end) |> expect(:get, 0, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: %{}}} + {:ok, %{status: 200, headers: [], body: %{}}} end) |> expect(:get, 0, fn ^eu_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: %{}}} + {:ok, %{status: 200, headers: [], body: %{}}} end) assert {:ok, _} = ConfigFetcher.fetch(fetcher, nil) @@ -173,13 +172,13 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do MockAPI |> expect(:get, 2, fn ^custom_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_eu}} + {:ok, %{status: 200, headers: [], body: config_to_eu}} end) |> expect(:get, 0, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: %{}}} + {:ok, %{status: 200, headers: [], body: %{}}} end) |> expect(:get, 0, fn ^eu_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: %{}}} + {:ok, %{status: 200, headers: [], body: %{}}} end) assert {:ok, _} = ConfigFetcher.fetch(fetcher, nil) @@ -197,10 +196,10 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do MockAPI |> expect(:get, 1, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_forced}} + {:ok, %{status: 200, headers: [], body: config_to_forced}} end) |> expect(:get, 2, fn ^forced_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: "{}"}} + {:ok, %{status: 200, headers: [], body: "{}"}} end) |> expect(:get, 0, fn ^eu_url, _headers, _options -> :not_called @@ -226,16 +225,16 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do MockAPI |> expect(:get, 0, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: %{}}} + {:ok, %{status: 200, headers: [], body: %{}}} end) |> expect(:get, 0, fn ^eu_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: %{}}} + {:ok, %{status: 200, headers: [], body: %{}}} end) |> expect(:get, 1, fn ^custom_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_forced}} + {:ok, %{status: 200, headers: [], body: config_to_forced}} end) |> expect(:get, 3, fn ^forced_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_forced}} + {:ok, %{status: 200, headers: [], body: config_to_forced}} end) assert {:ok, _} = ConfigFetcher.fetch(fetcher, nil) @@ -253,20 +252,20 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do MockAPI |> expect(:get, 1, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_eu}} + {:ok, %{status: 200, headers: [], body: config_to_eu}} end) |> expect(:get, 1, fn ^eu_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_global}} + {:ok, %{status: 200, headers: [], body: config_to_global}} end) assert {:ok, _} = ConfigFetcher.fetch(fetcher, nil) MockAPI |> expect(:get, 1, fn ^eu_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_global}} + {:ok, %{status: 200, headers: [], body: config_to_global}} end) |> expect(:get, 1, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_eu}} + {:ok, %{status: 200, headers: [], body: config_to_eu}} end) assert {:ok, _} = ConfigFetcher.fetch(fetcher, nil) diff --git a/test/support/cache_policy_case.ex b/test/support/cache_policy_case.ex index b2345f4b..a7f9de5a 100644 --- a/test/support/cache_policy_case.ex +++ b/test/support/cache_policy_case.ex @@ -14,7 +14,6 @@ defmodule ConfigCat.CachePolicyCase do alias ConfigCat.Hooks alias ConfigCat.InMemoryCache alias ConfigCat.MockFetcher - alias HTTPoison.Response using do quote do @@ -112,7 +111,7 @@ defmodule ConfigCat.CachePolicyCase do @spec assert_returns_error(function()) :: true def assert_returns_error(force_refresh_fn) do - response = %Response{status_code: 503} + response = %{status: 503, body: "", headers: []} error = FetchError.exception(reason: response, transient?: true) stub(MockFetcher, :fetch, fn _id, _etag -> {:error, error} end) diff --git a/test/support/mocks.ex b/test/support/mocks.ex index 6b64119b..257b2b92 100644 --- a/test/support/mocks.ex +++ b/test/support/mocks.ex @@ -1,4 +1,4 @@ -Mox.defmock(ConfigCat.MockAPI, for: HTTPoison.Base) +Mox.defmock(ConfigCat.MockAPI, for: ConfigCat.HTTPClient) Mox.defmock(ConfigCat.MockCachePolicy, for: ConfigCat.CachePolicy.Behaviour) Mox.defmock(ConfigCat.MockConfigCache, for: ConfigCat.ConfigCache) Mox.defmock(ConfigCat.MockFetcher, for: ConfigCat.ConfigFetcher) From d6ab572aed89f4b2fb36551be7fcfa5518e3d10c Mon Sep 17 00:00:00 2001 From: Jakub Jasiulewicz Date: Tue, 12 May 2026 16:46:15 +0200 Subject: [PATCH 2/6] Make HTTPoison optional and add a Finch adapter HTTPoison and Finch are both declared as optional deps. ConfigCat.API guards itself with Code.ensure_loaded?/1 and raises a clear error if neither HTTPoison nor a custom :http_client is configured. ConfigCat.HTTPClient.Finch is a drop-in adapter that translates the SDK's HTTPoison-shaped timeout options to Finch's request options and reads the pool name from application config. --- lib/config_cat/api.ex | 30 +++++++++- lib/config_cat/http_client/finch.ex | 92 +++++++++++++++++++++++++++++ mix.exs | 3 +- mix.lock | 7 +++ 4 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 lib/config_cat/http_client/finch.ex diff --git a/lib/config_cat/api.ex b/lib/config_cat/api.ex index c62eb0f2..d3c63e15 100644 --- a/lib/config_cat/api.ex +++ b/lib/config_cat/api.ex @@ -1,13 +1,24 @@ defmodule ConfigCat.API do - @moduledoc false + @moduledoc """ + Default `ConfigCat.HTTPClient` adapter built on + [HTTPoison](https://hex.pm/packages/httpoison). + + HTTPoison is declared as an optional dependency, so applications that supply + their own `:http_client` adapter do not need to pull it in. If neither + HTTPoison nor a custom adapter is configured, calling `get/3` raises with + instructions for the user. + """ @behaviour ConfigCat.HTTPClient + @compile {:no_warn_undefined, [HTTPoison, HTTPoison.Response, HTTPoison.Error]} + @timeout_reasons ~w(checkout_timeout timeout connect_timeout)a @transient_reasons @timeout_reasons ++ ~w(closed econnrefused nxdomain)a @impl ConfigCat.HTTPClient def get(url, headers, opts) do + ensure_httpoison!() headers = [{"Accept", "application/json"} | headers] case HTTPoison.get(url, headers, opts) do @@ -18,4 +29,21 @@ defmodule ConfigCat.API do {:error, %{reason: reason, transient?: reason in @transient_reasons}} end end + + defp ensure_httpoison! do + if Code.ensure_loaded?(HTTPoison) do + :ok + else + raise """ + ConfigCat.API requires the optional :httpoison dependency. + + Either add it to your deps: + + {:httpoison, "~> 2.0"} + + or provide your own HTTP client by passing `:http_client` to + `ConfigCat.start_link/1`. See `ConfigCat.HTTPClient` for the behaviour. + """ + end + end end diff --git a/lib/config_cat/http_client/finch.ex b/lib/config_cat/http_client/finch.ex new file mode 100644 index 00000000..31dadbdf --- /dev/null +++ b/lib/config_cat/http_client/finch.ex @@ -0,0 +1,92 @@ +defmodule ConfigCat.HTTPClient.Finch do + @moduledoc """ + `ConfigCat.HTTPClient` adapter built on + [Finch](https://hex.pm/packages/finch). + + Finch is declared as an optional dependency, so it is only pulled in when + the application elects to use it. + + ## Usage + + Start a Finch pool in your application supervision tree, configure the + pool name for the adapter, and select it as the SDK's HTTP client: + + # config/config.exs + config :configcat, ConfigCat.HTTPClient.Finch, name: MyApp.Finch + + # application.ex + children = [ + {Finch, name: MyApp.Finch}, + {ConfigCat, sdk_key: "...", http_client: ConfigCat.HTTPClient.Finch} + ] + + ## Option translation + + The SDK passes HTTPoison-shaped options to the adapter; this module + translates the relevant ones to Finch's request options: + + - `:timeout` → Finch `:pool_timeout` + - `:recv_timeout` → Finch `:receive_timeout` + - `:proxy` → ignored (configure your Finch pool instead) + + Errors classified as transient: `:timeout`, `:closed`, `:econnrefused`, + `:nxdomain`. + """ + + @behaviour ConfigCat.HTTPClient + + @compile {:no_warn_undefined, [Finch, Finch.Request, Finch.Response, Mint.TransportError]} + + @transient_reasons ~w(timeout closed econnrefused nxdomain)a + + @impl ConfigCat.HTTPClient + def get(url, headers, opts) do + ensure_finch!() + + headers = [{"Accept", "application/json"} | headers] + request = Finch.build(:get, url, headers) + request_opts = build_request_opts(opts) + + case Finch.request(request, finch_name(), request_opts) do + {:ok, %Finch.Response{status: status, body: body, headers: response_headers}} -> + {:ok, %{status: status, body: body, headers: response_headers}} + + {:error, %{reason: reason}} -> + {:error, %{reason: reason, transient?: reason in @transient_reasons}} + + {:error, reason} -> + {:error, %{reason: reason, transient?: false}} + end + end + + defp finch_name do + Application.get_env(:configcat, __MODULE__, [])[:name] || + raise ArgumentError, """ + #{inspect(__MODULE__)} requires the name of a Finch pool. Configure it: + + config :configcat, #{inspect(__MODULE__)}, name: MyApp.Finch + """ + end + + defp build_request_opts(opts) do + Enum.flat_map(opts, fn + {:timeout, value} -> [pool_timeout: value] + {:recv_timeout, value} -> [receive_timeout: value] + _ -> [] + end) + end + + defp ensure_finch! do + if Code.ensure_loaded?(Finch) do + :ok + else + raise """ + #{inspect(__MODULE__)} requires the optional :finch dependency. + + Add it to your deps: + + {:finch, "~> 0.18"} + """ + end + end +end diff --git a/mix.exs b/mix.exs index 25899bc4..eb037dcf 100644 --- a/mix.exs +++ b/mix.exs @@ -77,7 +77,8 @@ defmodule ConfigCat.MixProject do {:elixir_uuid, "~> 1.2"}, {:ex_doc, "~> 0.31.0", only: :dev, runtime: false}, {:excoveralls, "~> 0.18.0", only: :test}, - {:httpoison, "~> 1.7 or ~> 2.0"}, + {:finch, "~> 0.18", optional: true}, + {:httpoison, "~> 1.7 or ~> 2.0", optional: true}, {:jason, "~> 1.2"}, {:mix_test_interactive, "~> 1.2", only: :dev, runtime: false}, {:mox, "~> 1.1", only: :test}, diff --git a/mix.lock b/mix.lock index 3227b393..10abce2b 100644 --- a/mix.lock +++ b/mix.lock @@ -9,7 +9,9 @@ "ex_doc": {:hex, :ex_doc, "0.31.2", "8b06d0a5ac69e1a54df35519c951f1f44a7b7ca9a5bb7a260cd8a174d6322ece", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "317346c14febaba9ca40fd97b5b5919f7751fb85d399cc8e7e8872049f37e0af"}, "excoveralls": {:hex, :excoveralls, "0.18.2", "86efd87a0676a3198ff50b8c77620ea2f445e7d414afa9ec6c4ba84c9f8bdcc2", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "230262c418f0de64077626a498bd4fdf1126d5c2559bb0e6b43deac3005225a4"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "httpoison": {:hex, :httpoison, "2.2.3", "a599d4b34004cc60678999445da53b5e653630651d4da3d14675fedc9dd34bd6", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "fa0f2e3646d3762fdc73edb532104c8619c7636a6997d20af4003da6cfc53e53"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, @@ -17,13 +19,18 @@ "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.5", "e0ff5a7c708dda34311f7522a8758e23bfcd7d8d8068dc312b5eb41c6fd76eba", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "94d2e986428585a21516d7d7149781480013c56e30c6a233534bedf38867a59a"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, + "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"}, "mix_test_interactive": {:hex, :mix_test_interactive, "1.2.2", "72f72faa7007d6cb9634ee5f6989b25ee5b194c5729e5e45a962e68b2e217374", [:mix], [{:file_system, "~> 0.2", [hex: :file_system, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "f49f2a70d00aee93418506dde4d95387fe56bdba501ef9d2aa06ea07d4823508"}, "mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "styler": {:hex, :styler, "0.11.9", "2595393b94e660cd6e8b582876337cc50ff047d184ccbed42fdad2bfd5d78af5", [:mix], [], "hexpm", "8b7806ba1fdc94d0a75127c56875f91db89b75117fcc67572661010c13e1f259"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, "tz": {:hex, :tz, "0.26.6", "4d46178dd5bc4d2c1e78c9affcc3fd46764e29cd2a148c06666edb83cb18629f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "9ca97ea48b412f2404740867f6c321ee8ce112602035bb79b0b90c9c03174652"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, From 1d0489d0a936969ea2683deb99e806c2a7189bdc Mon Sep 17 00:00:00 2001 From: Jakub Jasiulewicz Date: Tue, 12 May 2026 16:48:49 +0200 Subject: [PATCH 3/6] Raise ArgumentError when HTTPoison is missing --- lib/config_cat/api.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/config_cat/api.ex b/lib/config_cat/api.ex index d3c63e15..8b103e5e 100644 --- a/lib/config_cat/api.ex +++ b/lib/config_cat/api.ex @@ -34,7 +34,7 @@ defmodule ConfigCat.API do if Code.ensure_loaded?(HTTPoison) do :ok else - raise """ + raise ArgumentError, """ ConfigCat.API requires the optional :httpoison dependency. Either add it to your deps: From b846eb7a69c3c524be7f5bd8453fdc025ad0fefd Mon Sep 17 00:00:00 2001 From: Jakub Jasiulewicz Date: Tue, 12 May 2026 16:49:47 +0200 Subject: [PATCH 4/6] Document pluggable HTTP client in README --- README.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b4da72ad..a4f200ad 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ ConfigCat is a [hosted feature flag service](http://configcat.com). Manage featu ```elixir def deps do [ - {:configcat, "~> 4.0.4"} + {:configcat, "~> 4.0.4"}, + # pick an HTTP client (see "HTTP Client" below) — the SDK ships none by default + {:httpoison, "~> 2.0"} ] end ``` @@ -82,6 +84,58 @@ end The ConfigCat SDK supports 3 different polling mechanisms to acquire the setting values from ConfigCat. After latest setting values are downloaded, they are stored in the internal cache then all requests are served from there. Read more about Polling Modes and how to use them at [ConfigCat Docs](https://configcat.com/docs/sdk-reference/elixir/). +## HTTP Client + +The SDK fetches configurations over HTTP through a swappable transport that +implements the `ConfigCat.HTTPClient` behaviour. + +Both `:httpoison` and `:finch` are declared as **optional** dependencies. Add +whichever you prefer to your own `mix.exs`. + +### Default — HTTPoison + +```elixir +def deps do + [ + {:configcat, "~> 4.0.4"}, + {:httpoison, "~> 2.0"} + ] +end +``` + +No further configuration needed — `ConfigCat.API` is used by default. + +### Finch + +```elixir +def deps do + [ + {:configcat, "~> 4.0.4"}, + {:finch, "~> 0.18"} + ] +end +``` + +```elixir +# config/config.exs +config :configcat, ConfigCat.HTTPClient.Finch, name: MyApp.Finch + +# application.ex +children = [ + {Finch, name: MyApp.Finch}, + {ConfigCat, sdk_key: "YOUR SDK KEY", http_client: ConfigCat.HTTPClient.Finch} +] +``` + +### Custom adapter + +Implement `ConfigCat.HTTPClient` to use any other HTTP client (Req, Mint, +Tesla, a test stub, ...) and pass the module via `:http_client`: + +```elixir +{ConfigCat, sdk_key: "YOUR SDK KEY", http_client: MyApp.ConfigCatClient} +``` + ## Need help? https://configcat.com/support From ed271f8bb927c87aca825ae316e9b7cc3af2dadf Mon Sep 17 00:00:00 2001 From: Jakub Jasiulewicz Date: Wed, 13 May 2026 07:57:01 +0200 Subject: [PATCH 5/6] Fix struct patterns that defeated optional HTTP deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pattern matching on %HTTPoison.Response{} / %HTTPoison.Error{} in ConfigCat.API and %Finch.Response{} in ConfigCat.HTTPClient.Finch forced the compiler to resolve those structs at compile time, so the SDK could not compile when the optional dep was absent. Match on the underlying map shape instead — same runtime semantics, no compile-time coupling to the optional package. --- lib/config_cat/api.ex | 6 +++--- lib/config_cat/http_client/finch.ex | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/config_cat/api.ex b/lib/config_cat/api.ex index 8b103e5e..ce65cf2a 100644 --- a/lib/config_cat/api.ex +++ b/lib/config_cat/api.ex @@ -11,7 +11,7 @@ defmodule ConfigCat.API do @behaviour ConfigCat.HTTPClient - @compile {:no_warn_undefined, [HTTPoison, HTTPoison.Response, HTTPoison.Error]} + @compile {:no_warn_undefined, [HTTPoison]} @timeout_reasons ~w(checkout_timeout timeout connect_timeout)a @transient_reasons @timeout_reasons ++ ~w(closed econnrefused nxdomain)a @@ -22,10 +22,10 @@ defmodule ConfigCat.API do headers = [{"Accept", "application/json"} | headers] case HTTPoison.get(url, headers, opts) do - {:ok, %HTTPoison.Response{status_code: status, body: body, headers: response_headers}} -> + {:ok, %{status_code: status, body: body, headers: response_headers}} -> {:ok, %{status: status, body: body, headers: response_headers}} - {:error, %HTTPoison.Error{reason: reason}} -> + {:error, %{reason: reason}} -> {:error, %{reason: reason, transient?: reason in @transient_reasons}} end end diff --git a/lib/config_cat/http_client/finch.ex b/lib/config_cat/http_client/finch.ex index 31dadbdf..91906354 100644 --- a/lib/config_cat/http_client/finch.ex +++ b/lib/config_cat/http_client/finch.ex @@ -35,7 +35,7 @@ defmodule ConfigCat.HTTPClient.Finch do @behaviour ConfigCat.HTTPClient - @compile {:no_warn_undefined, [Finch, Finch.Request, Finch.Response, Mint.TransportError]} + @compile {:no_warn_undefined, [Finch]} @transient_reasons ~w(timeout closed econnrefused nxdomain)a @@ -48,7 +48,7 @@ defmodule ConfigCat.HTTPClient.Finch do request_opts = build_request_opts(opts) case Finch.request(request, finch_name(), request_opts) do - {:ok, %Finch.Response{status: status, body: body, headers: response_headers}} -> + {:ok, %{status: status, body: body, headers: response_headers}} -> {:ok, %{status: status, body: body, headers: response_headers}} {:error, %{reason: reason}} -> From 45400589ac1cc9430525a9942c04c8c2589439da Mon Sep 17 00:00:00 2001 From: Jakub Jasiulewicz Date: Wed, 13 May 2026 08:09:51 +0200 Subject: [PATCH 6/6] Rename ConfigCat.API to ConfigCat.HTTPClient.HTTPoison Group the two built-in adapters under one namespace so they're discoverable side-by-side in HexDocs: ConfigCat.HTTPClient.HTTPoison (default) ConfigCat.HTTPClient.Finch --- README.md | 2 +- lib/config_cat.ex | 2 +- lib/config_cat/config_fetcher.ex | 2 +- lib/config_cat/http_client.ex | 2 +- lib/config_cat/{api.ex => http_client/httpoison.ex} | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) rename lib/config_cat/{api.ex => http_client/httpoison.ex} (92%) diff --git a/README.md b/README.md index a4f200ad..075c0219 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ def deps do end ``` -No further configuration needed — `ConfigCat.API` is used by default. +No further configuration needed — `ConfigCat.HTTPClient.HTTPoison` is used by default. ### Finch diff --git a/lib/config_cat.ex b/lib/config_cat.ex index 959fdc20..f10ecb31 100644 --- a/lib/config_cat.ex +++ b/lib/config_cat.ex @@ -97,7 +97,7 @@ defmodule ConfigCat do - `http_client`: **OPTIONAL** Module implementing the `ConfigCat.HTTPClient` behaviour, used to perform HTTP requests against the ConfigCat CDN. Defaults - to `ConfigCat.API`, which is built on + to `ConfigCat.HTTPClient.HTTPoison`, which is built on [HTTPoison](https://hex.pm/packages/httpoison). Provide your own adapter to route requests through Finch, Req, Mint, Tesla, or any other client (or to stub HTTP in tests). diff --git a/lib/config_cat/config_fetcher.ex b/lib/config_cat/config_fetcher.ex index 1da1d1f5..4db2f1d8 100644 --- a/lib/config_cat/config_fetcher.ex +++ b/lib/config_cat/config_fetcher.ex @@ -52,7 +52,7 @@ defmodule ConfigCat.CacheControlConfigFetcher do use TypedStruct typedstruct enforce: true do - field :http_client, module(), default: ConfigCat.API + field :http_client, module(), default: ConfigCat.HTTPClient.HTTPoison field :base_url, String.t() field :callers, [GenServer.from()], default: [] field :connect_timeout_milliseconds, non_neg_integer(), default: 8_000 diff --git a/lib/config_cat/http_client.ex b/lib/config_cat/http_client.ex index 061eb445..a5f92006 100644 --- a/lib/config_cat/http_client.ex +++ b/lib/config_cat/http_client.ex @@ -2,7 +2,7 @@ defmodule ConfigCat.HTTPClient do @moduledoc """ Behaviour for the HTTP transport used by the ConfigCat SDK. - The SDK ships with `ConfigCat.API`, a default adapter built on + The SDK ships with `ConfigCat.HTTPClient.HTTPoison`, a default adapter built on [HTTPoison](https://hex.pm/packages/httpoison). To plug in a different HTTP client (Finch, Req, Mint, Tesla, a test stub, ...) implement this behaviour and pass the module via the `:http_client` option to `ConfigCat.start_link/1`. diff --git a/lib/config_cat/api.ex b/lib/config_cat/http_client/httpoison.ex similarity index 92% rename from lib/config_cat/api.ex rename to lib/config_cat/http_client/httpoison.ex index ce65cf2a..823189c9 100644 --- a/lib/config_cat/api.ex +++ b/lib/config_cat/http_client/httpoison.ex @@ -1,4 +1,4 @@ -defmodule ConfigCat.API do +defmodule ConfigCat.HTTPClient.HTTPoison do @moduledoc """ Default `ConfigCat.HTTPClient` adapter built on [HTTPoison](https://hex.pm/packages/httpoison). @@ -35,7 +35,7 @@ defmodule ConfigCat.API do :ok else raise ArgumentError, """ - ConfigCat.API requires the optional :httpoison dependency. + #{inspect(__MODULE__)} requires the optional :httpoison dependency. Either add it to your deps: