summaryrefslogtreecommitdiffstats
path: root/lib/cepis/client.ex
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:19:34 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:19:34 -0300
commit075683ff44a254e99adbad8a0372ef80ff06151a (patch)
tree648289c8485afc6f003a3f719fb937c21f149311 /lib/cepis/client.ex
downloadelixir-075683ff44a254e99adbad8a0372ef80ff06151a.tar.gz
Initial importv0.1.0
Diffstat (limited to 'lib/cepis/client.ex')
-rw-r--r--lib/cepis/client.ex114
1 files changed, 114 insertions, 0 deletions
diff --git a/lib/cepis/client.ex b/lib/cepis/client.ex
new file mode 100644
index 0000000..a5e0d58
--- /dev/null
+++ b/lib/cepis/client.ex
@@ -0,0 +1,114 @@
+defmodule Cepis.Client do
+ @moduledoc """
+ Configuration for the cep.is API.
+
+ Every function in `Cepis` takes a client as its last argument. Passing a
+ keyword list builds one on the fly; building a `%Cepis.Client{}` once with
+ `new/1` and reusing it is equivalent and slightly cheaper. The struct is
+ plain data with no process behind it, so it is safe to share across
+ processes.
+
+ client = Cepis.Client.new(base_url: "http://localhost:8080", timeout: 5_000)
+ {:ok, address} = Cepis.lookup("01001000", client)
+
+ **All durations are in milliseconds**, as everywhere else in Elixir.
+
+ ## Options
+
+ * `:base_url` — where the API lives, default `"https://cep.is"`. Point it
+ at a local instance (the API repo brings one up on
+ `http://localhost:8080` with `just up`) or at a test server. It is the
+ only option that usually changes between environments. Trailing slashes
+ are stripped, so it cannot produce doubled separators that some proxies
+ normalise and others 404.
+
+ * `:timeout` — milliseconds for one attempt, default `10_000`. This is
+ `:httpc`'s per-request timeout, used for the connect phase too; it is not
+ a cap on the whole call across retries. That cap is `:total_timeout`.
+
+ * `:max_attempts` — how many attempts in total, counting the first, default
+ `3`. `1` disables retrying. The default covers the common case (one
+ transient failure) without turning a real outage into a long wait.
+
+ * `:backoff_base` — base of the exponential wait, default `500`. The actual
+ wait is drawn from `[0, base * 2^(n-1)]` — *full jitter*. The randomness
+ is not decoration: without it, N clients that fail together come back
+ together and reproduce the spike that took the service down.
+
+ * `:backoff_max` — cap on each individual wait, default `8_000`. It applies
+ to a server-sent `Retry-After` too, or a `Retry-After: 3600` from a
+ misconfigured proxy would park the caller for an hour.
+
+ * `:total_timeout` — cap on the whole call, attempts and waits included,
+ default `nil` (only `:max_attempts` bounds it). Set it in code serving a
+ web request: without it the worst case is `max_attempts * timeout` plus
+ the sum of the backoffs.
+
+ * `:user_agent` — overrides the `User-Agent`. The default identifies the
+ library and its version, which is what lets the server diagnose an old
+ client misbehaving.
+ """
+
+ @default_base_url "https://cep.is"
+ @version Mix.Project.config()[:version]
+
+ defstruct base_url: @default_base_url,
+ timeout: 10_000,
+ max_attempts: 3,
+ backoff_base: 500,
+ backoff_max: 8_000,
+ total_timeout: nil,
+ user_agent: nil
+
+ @type t :: %__MODULE__{
+ base_url: String.t(),
+ timeout: pos_integer(),
+ max_attempts: pos_integer(),
+ backoff_base: non_neg_integer(),
+ backoff_max: non_neg_integer(),
+ total_timeout: pos_integer() | nil,
+ user_agent: String.t()
+ }
+
+ @doc """
+ The API's address when none is configured: `"https://cep.is"`.
+ """
+ @spec default_base_url() :: String.t()
+ def default_base_url, do: @default_base_url
+
+ @doc """
+ Build a client from options, or return one that is already built.
+
+ See the module documentation for every option. Raises `ArgumentError` for an
+ impossible configuration and `KeyError` for an unknown option.
+
+ iex> Cepis.Client.new(base_url: "http://localhost:8080/").base_url
+ "http://localhost:8080"
+ """
+ @spec new(t() | keyword()) :: t()
+ def new(%__MODULE__{} = client), do: client
+
+ def new(opts) when is_list(opts) do
+ client = struct!(__MODULE__, opts)
+
+ if client.max_attempts < 1 do
+ raise ArgumentError, "max_attempts must be >= 1, got #{inspect(client.max_attempts)}"
+ end
+
+ if client.timeout <= 0 do
+ raise ArgumentError, "timeout must be > 0, got #{inspect(client.timeout)}"
+ end
+
+ %{
+ client
+ | base_url: String.trim_trailing(client.base_url, "/"),
+ user_agent: client.user_agent || default_user_agent()
+ }
+ end
+
+ @doc """
+ The `User-Agent` sent when none is configured.
+ """
+ @spec default_user_agent() :: String.t()
+ def default_user_agent, do: "cepis-elixir/#{@version} (+https://cep.is)"
+end