Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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.HTTPClient.HTTPoison` 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
Expand Down
12 changes: 12 additions & 0 deletions lib/config_cat.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.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).

```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
Expand Down Expand Up @@ -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()}
Expand Down
10 changes: 0 additions & 10 deletions lib/config_cat/api.ex

This file was deleted.

28 changes: 15 additions & 13 deletions lib/config_cat/config_fetcher.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ defmodule ConfigCat.ConfigFetcher do
@moduledoc false

alias ConfigCat.ConfigEntry
alias HTTPoison.Response

defmodule FetchError do
@moduledoc false
Expand Down Expand Up @@ -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
Expand All @@ -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.HTTPClient.HTTPoison
field :base_url, String.t()
field :callers, [GenServer.from()], default: []
field :connect_timeout_milliseconds, non_neg_integer(), default: 8_000
Expand Down Expand Up @@ -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()}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)}")

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
43 changes: 43 additions & 0 deletions lib/config_cat/http_client.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
defmodule ConfigCat.HTTPClient do
@moduledoc """
Behaviour for the HTTP transport used by the ConfigCat SDK.

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`.

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
92 changes: 92 additions & 0 deletions lib/config_cat/http_client/finch.ex
Original file line number Diff line number Diff line change
@@ -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]}

@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, %{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
49 changes: 49 additions & 0 deletions lib/config_cat/http_client/httpoison.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
defmodule ConfigCat.HTTPClient.HTTPoison do
@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]}

@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
{:ok, %{status_code: 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}}
end
end

defp ensure_httpoison! do
if Code.ensure_loaded?(HTTPoison) do
:ok
else
raise ArgumentError, """
#{inspect(__MODULE__)} 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
Loading
Loading