summaryrefslogtreecommitdiffstats
path: root/lib/cepis/client.ex
blob: a5e0d583fb12e08fe7826a0debcae29f99bc058e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
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